Merge remote-tracking branch 'origin/main' into pr1654

# Conflicts:
#	ROADMAP.md
This commit is contained in:
FredApps committed 2026-11-02 18:25:00 +01:00
commit f704f481d5
57 files changed
+6873 -158

No files matched your search

Generated
+22 -21
View File
@@ -1306,7 +1306,7 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"arboard",
"craft-update",
@@ -1330,7 +1330,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1346,7 +1346,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -1358,7 +1358,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1378,7 +1378,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1391,7 +1391,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1431,7 +1431,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1441,7 +1441,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1454,7 +1454,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1466,7 +1466,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1479,7 +1479,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1494,7 +1494,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1504,6 +1504,7 @@ dependencies = [
"log",
"quick-xml 0.41.0",
"resvg",
"skrifa 0.47.0",
"tiff",
"zune-core",
"zune-jpeg",
@@ -1511,7 +1512,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1524,7 +1525,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"chrono",
"craft-cms",
@@ -1550,7 +1551,7 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"chrono",
"designcraft-color",
@@ -1568,7 +1569,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1587,7 +1588,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"designcraft-doc",
"quick-xml 0.41.0",
@@ -1596,7 +1597,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1608,7 +1609,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"craft-dialogs",
"craft-keymap",
@@ -1640,7 +1641,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -7604,7 +7605,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.2"
version = "0.16.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.15.2"
version = "0.16.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+14
View File
@@ -32,6 +32,20 @@ list (author, source, license) is in ASSETS.md.
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- Dutch hyphenation patterns, embedded through the hypher crate (feature "dutch"):
hyph-nl.tex 1.1 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
Copyright (C) 1996 Piet Tutelaers. MIT License:
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions: The above copyright notice
and this permission notice shall be included in all copies or substantial portions of the
Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- Portuguese hyphenation patterns, embedded through the hypher crate (feature "portuguese"):
hyph-pt.tex 1.4 from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
+10 -1
View File
@@ -336,7 +336,16 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | **Page layout and publishing · you are here** | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+9 -2
View File
@@ -354,7 +354,7 @@ A string without a translation shows in English and fails no check; `cargo xtask
| Vietnamese | vi | none | 0% |
Also shipped: Czech (99%), Italian (85%), Ukrainian (83%), Polish (99%).
Also shipped: Italian (85%), Ukrainian (83%), Georgian (100%).
Also shipped: Italian (85%), Ukrainian (83%), Georgian (100%), Swedish (99%).
## Upcoming
@@ -382,6 +382,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
## Progress log
- **2026-11-02** · Windows: the custom title bar window stays borderless, so the content is no longer drawn offset by the caption and border (black bars at the top and left, clicks landing off target). The workaround goes when winit carries the fix (storytold/winit#17). No readiness percentage change.
- **2026-10-30** · The Color Picker takes an eyedropper: dragged from the picker onto the page, it takes the colour the canvas shows (proofed under Proof Colors). No readiness percentage change.
- **2026-10-30** · The Color Picker is laid out as the reference app's: a field and slider for the component chosen, HSB, Lab, RGB and CMYK values, the old colour, and Add CMYK Swatch; Enter and Esc close it once. No readiness percentage change.
- **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated in their existing outline position on update without replacing or moving surrounding manual bookmarks. The setting survives native and IDML files. No readiness percentage change.
@@ -389,7 +390,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing targets only the selected parent page and preserves opposite-page margins and document overrides; document commands retain their first-page default before the canvas is initialized. No readiness percentage change.
- **2026-10-31** · `font.list` reports the embedding licence of the face that sets each font's text: its OS/2 `fsType` and what the bits mean (`restrictedLicence`, `bitmapOnly`, `noSubsetting`, `editable`, `previewPrint`; null when the font is missing or has no OS/2 table). A script or host that makes PDFs can check every face, bundled and installed ones included, without reading font files. Export behaviour is unchanged. No readiness percentage change.
- **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. No readiness percentage change.
- **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change.
- **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change.
- **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change.
@@ -406,6 +408,7 @@ Newest first.
- **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change.
- **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change.
- **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change.
- **2026-10-17** · HU1: initial Hungarian (`hu` / Magyar) interface localization (updated 2026-11-01): 1,906 catalog entries, four context captions and all 37 tool labels. AI-assisted; the initial catalog was personally reviewed in the app by Veress Róbert, a native Hungarian speaker with over 30 years of DTP experience. Uncatalogued strings keep their English fallback. Hungarian spelling and hyphenation are separate work in #1484. No readiness percentage change.
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-20** · PDF/X-1a:2003 export (craft-libs#194): `file.exportPdf {standard: "x1a"}`, the Export PDF dialog's PDF/X-1a:2003 preset and standard. The PDF/X-4 print file is flattened in its inks by craft-prepress (no transparency; spot, process, overprint and live text kept; complex regions as DeviceN images at the Transparency Flattener preset's resolution), then written as PDF 1.4 with the X-1a identification and checked; `validate-pdf --standard x1a`. Soft drop shadows reach the flattener in their swatch's inks; other soft effects are still RGB images. No readiness percentage change.
- **2026-10-20** · Print color (craft-libs#190): CMYK print PDFs blend in DeviceCMYK through craft-pdf's print settings instead of byte rewrites, with the CMYK working space as the output intent in PDF/X-4 only; overprint (Overprint Fill, Stroke and Gap, overprinting text, 100% [Black]) is written as `/op` `/OP` `/OPM 1`; Knockout Group writes `/K true` instead of becoming an image; File › Print prints the PDF Export PDF writes. No readiness percentage change.
@@ -539,6 +542,10 @@ Newest first.
- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change.
- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change.
- **2026-10-15** · Swedish interface (`sv`, Preferences ▸ General ▸ Interface Language ▸ Svenska): menus, panels,
dialogs and messages, 2,857 of the 2,891 interface strings (99%) in the missing-translations report; the other 34
show in English. AI-drafted with Claude Code from the English source; spot-checked by a native speaker, not every
string reviewed. No readiness percentage change.
- **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change.
- **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change.
- **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `<Image FillColor>` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change.
+1 -2
View File
@@ -156,7 +156,6 @@ fn report(r: Result<(), String>) -> ExitCode {
fn validate_pdf(args: &[String]) -> Result<(), String> {
const USAGE: &str = "usage: designcraft-cli validate-pdf FILE.pdf [--standard x4|x1a]";
const X1A: &[u8] = b"/GTS_PDFXVersion(PDF/X-1:";
let mut path = None;
let mut standard = None;
let mut it = args.iter();
@@ -172,7 +171,7 @@ fn validate_pdf(args: &[String]) -> Result<(), String> {
let standard = match standard.map(designcraft_pdf::Standard::parse) {
Some(Some(s)) => s,
Some(None) => return Err(USAGE.into()),
None if bytes.windows(X1A.len()).any(|w| w == X1A) => designcraft_pdf::Standard::PdfX1a,
None if designcraft_pdf::pdfx_version(&bytes).is_some_and(|v| v.starts_with("PDF/X-1:")) => designcraft_pdf::Standard::PdfX1a,
None => designcraft_pdf::Standard::PdfX4,
};
let (name, issues) = match standard {
+2 -1
View File
@@ -37,7 +37,8 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
craft-update = { workspace = true }
# EnumDisplayDevices (src/gpu.rs): which GPU drives the display, so the window draws on the GPU
# that shows it; drawing on a GPU without a monitor reset the display driver. Safe wrappers over
# Win32 (MIT); the `user` feature is User32.dll only.
# Win32 (MIT); the `user` feature is User32.dll only. Also the window frame for the custom title
# bar (src/window_frame.rs, window styles).
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
[target.'cfg(target_os = "macos")'.dependencies]
+4
View File
@@ -22,6 +22,7 @@ mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod profile;
mod window_frame;
use designcraft_engine::Session;
use designcraft_ui_egui::{DesignApp, Services};
@@ -428,6 +429,9 @@ fn main() -> std::process::ExitCode {
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = cfg!(target_os = "windows");
// The custom title bar's undecorated window must stay borderless, or its caption
// and border offset the content and the pointer on some Windows systems (#1295).
app.services.window_frame = window_frame::service(cc, app.custom_titlebar);
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
+352
View File
@@ -0,0 +1,352 @@
//! Keeps the custom (undecorated) title bar window truly borderless (#1295).
//!
//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems
//! (reported: Windows 10 LTSB, Intel HD Graphics 4600, 1366 × 768) the desktop compositor still
//! reserves that frame for the window surface: the content is drawn offset by the caption and
//! border, leaving black bars at the window's top-left, while the pointer keeps the un-offset
//! client coordinates, so it no longer points at what is drawn under it.
//!
//! winit 0.27 — before rust-windowing/winit#2419 — simply omitted those styles for undecorated
//! windows, and there was no offset. [`service`] restores that: it strips `WS_CAPTION` and
//! `WS_BORDER` and forces Windows to recalculate the frame. winit puts them back whenever it
//! changes the window flags (maximize, restore, …), so the returned guard checks every frame.
//!
//! Windows sizes a maximize by the frame its styles describe, so a window maximized while its
//! caption is gone is sized without one: its rect covers the whole monitor, taskbar included.
//! The shell reads a caption-less window covering its monitor as a full-screen app and drops the
//! taskbar's backdrop behind it — a black band on stock Windows 11, a glitched unresponsive one
//! under ExplorerPatcher (photocraft#3144). The guard therefore also trims a parked maximize's rect back to
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
//!
//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the
//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar
//! when the pointer reaches its edge (photocraft#4845). On each edge with an auto-hidden taskbar the guard
//! instead stops the window one pixel short of the monitor, as other custom-frame apps do.
//!
//! The long-term fix is in winit itself (storytold/winit#17); this module goes when that lands.
//!
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
pub use platform::service;
#[cfg(target_os = "windows")]
mod platform {
use designcraft_ui_egui::WindowFrameFn;
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use winsafe::prelude::Handle;
use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co};
/// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive.
type Rect = (i32, i32, i32, i32);
/// A monitor edge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Edge {
Left,
Top,
Right,
Bottom,
}
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
/// system title bar (the window is decorated) or its handle can't be found.
pub fn service(cc: &eframe::CreationContext<'_>, custom_titlebar: bool) -> Option<WindowFrameFn> {
if !custom_titlebar {
return None;
}
let target = match cc.window_handle().ok()?.as_raw() {
RawWindowHandle::Win32(h) => h.hwnd.get(),
_ => return None,
};
let hwnd = find_window(target)?;
reapply(&hwnd);
Some(Box::new(move || reapply(&hwnd)))
}
/// The top-level window with the given raw handle. `EnumWindows` hands out real `HWND`s, so
/// this matches winit's own window without wrapping a raw handle (which needs `unsafe`).
fn find_window(target: isize) -> Option<HWND> {
let mut found = None;
let _ = winsafe::EnumWindows(|hwnd| {
if hwnd.ptr() as isize == target {
found = Some(hwnd);
false
} else {
true
}
});
found
}
/// One pass of the per-frame guard: the styles winit put back come off again, and a parked
/// maximize stays clear of the taskbar.
fn reapply(hwnd: &HWND) {
strip(hwnd);
keep_clear_of_the_taskbar(hwnd);
}
/// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame. A no-op once they
/// are gone, so it is cheap to call every frame.
fn strip(hwnd: &HWND) {
let style = hwnd.style();
let without = borderless(style);
if without.raw() == style.raw() {
return;
}
hwnd.set_style(without);
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE::new(),
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
/// How far a parked maximize reaches past a monitor/work-area edge: the resize
/// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not
/// parked but being dragged or snapped, and is left alone.
const FRAME: i32 = 32;
/// Keep a parked maximize inside all four work-area edges, including a top taskbar.
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
if !hwnd.IsZoomed() {
return;
}
let (Ok(r), Ok(mi)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom));
// The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window
// covers the whole monitor, which a trimmed maximize no longer does.
let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work)
.or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten())
else {
return;
};
let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else {
return;
};
// Moving the outer frame while winit pins the client rect to the work area must
// not copy the old frame's pixels into the custom title bar.
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT { x: left, y: top },
SIZE { cx: width, cy: height },
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
let _ = hwnd.InvalidateRect(None, false);
}
/// Return the corrected outer rect in desktop coordinates, preserving the resize border.
/// Each edge must be parked just outside either the monitor or work-area edge. Accepting
/// both makes the guard idempotent and handles Windows already correcting some edges.
/// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed.
fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> {
let (l, t, r, b) = rect;
let (ml, mt, mr, mb) = monitor;
let (wl, wt, wr, wb) = work;
if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb {
return None;
}
// Infer the frame from an edge without a taskbar. On an obstructed edge, the
// monitor and work-area offsets can both look like valid borders (e.g. auto-hide).
let border = [
(ml == wl).then(|| ml.checked_sub(l)).flatten(),
(mt == wt).then(|| mt.checked_sub(t)).flatten(),
(mr == wr).then(|| r.checked_sub(mr)).flatten(),
(mb == wb).then(|| b.checked_sub(mb)).flatten(),
]
.into_iter()
.flatten()
.filter(|n| (0..=FRAME).contains(n))
.min()?;
let parked = |monitor: Option<i32>, work: Option<i32>| monitor == Some(border) || work == Some(border);
if !parked(ml.checked_sub(l), wl.checked_sub(l))
|| !parked(mt.checked_sub(t), wt.checked_sub(t))
|| !parked(r.checked_sub(mr), r.checked_sub(wr))
|| !parked(b.checked_sub(mb), b.checked_sub(wb))
{
return None;
}
let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?);
(corrected != rect).then_some(corrected)
}
/// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app.
fn covers(rect: Rect, monitor: Rect) -> bool {
rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3
}
/// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary
/// monitor's `Shell_SecondaryTrayWnd`), shown or hidden.
fn taskbar_edges(monitor: Rect) -> Vec<Edge> {
let mut edges = Vec::new();
for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] {
let mut after: Option<HWND> = None;
// One taskbar per monitor; the bound only guards against a misbehaving shell.
for _ in 0..64 {
let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else {
break;
};
if let Ok(r) = tray.GetWindowRect() {
edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor));
}
after = Some(tray);
}
}
edges
}
/// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre,
/// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its
/// edge with a sliver left on screen, so its centre may lie outside the monitor.
fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option<Edge> {
let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3));
let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3));
if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb {
return None;
}
// Doubled centre and distances, so nothing rounds.
let (cx, cy) = (tl + tr, tt + tb);
[(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)]
.into_iter()
.min_by_key(|&(_, distance)| distance)
.map(|(edge, _)| edge)
}
/// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a
/// taskbar reserves no work area (it auto-hides), so the shell still reveals it there.
/// `None` when there is nothing to trim.
fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option<Rect> {
if !covers(rect, monitor) {
return None;
}
let (mut l, mut t, mut r, mut b) = rect;
for edge in taskbars {
match edge {
Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?,
Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?,
Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?,
Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?,
_ => {}
}
}
let trimmed = (l, t, r, b);
(l < r && t < b && trimmed != rect).then_some(trimmed)
}
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
/// keep working, as they did with winit 0.27's undecorated windows.
fn borderless(style: co::WS) -> co::WS {
style & !(co::WS::CAPTION | co::WS::BORDER)
}
#[cfg(test)]
mod tests {
use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge};
use winsafe::co;
#[test]
fn the_borderless_style_drops_the_caption_and_border_only() {
// `WS_CAPTION` is 0x00c0_0000 and already contains `WS_BORDER` (0x0080_0000).
let decorated = co::WS::CAPTION | co::WS::BORDER | co::WS::SYSMENU | co::WS::THICKFRAME | co::WS::MAXIMIZEBOX | co::WS::MINIMIZEBOX;
let bare = borderless(decorated);
assert!(!bare.has(co::WS::CAPTION), "caption gone");
assert!(!bare.has(co::WS::BORDER), "border gone");
assert!(bare.has(co::WS::THICKFRAME), "edge resizing kept");
assert!(bare.has(co::WS::MAXIMIZEBOX) && bare.has(co::WS::SYSMENU), "window buttons kept");
// Already borderless: unchanged, so the per-frame call does nothing.
assert_eq!(bare.raw(), borderless(bare).raw());
}
#[test]
fn a_maximize_respects_taskbars_on_every_edge() {
let monitor = (0, 0, 1920, 1080);
let rect = (-8, -8, 1928, 1088);
for (work, expected) in [
((0, 0, 1920, 1046), (-8, -8, 1928, 1054)),
((0, 48, 1920, 1080), (-8, 40, 1928, 1088)),
((0, 2, 1920, 1080), (-8, -6, 1928, 1088)),
((0, 0, 1920, 1078), (-8, -8, 1928, 1086)),
((48, 0, 1920, 1080), (40, -8, 1928, 1088)),
((0, 0, 1872, 1080), (-8, -8, 1880, 1088)),
] {
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected");
}
}
#[test]
fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() {
let monitor = (-1920, -1080, 0, 0);
let work = (-1920, -1032, 0, 0);
for border in [0, 8, 16, 32] {
let rect = (-1920 - border, -1080 - border, border, border);
let expected = (-1920 - border, -1032 - border, border, border);
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), None);
}
}
#[test]
fn a_maximize_without_a_reserved_taskbar_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None);
}
#[test]
fn a_maximize_being_dragged_or_snapped_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 48, 1920, 1080);
assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
}
#[test]
fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() {
// photocraft#4845: an auto-hidden taskbar reserves no work area.
let monitor = (0, 0, 1920, 1080);
let rect = (-8, -8, 1928, 1088);
for (edge, expected) in [
(Edge::Bottom, (-8, -8, 1928, 1079)),
(Edge::Top, (-8, 1, 1928, 1088)),
(Edge::Left, (1, -8, 1928, 1088)),
(Edge::Right, (-8, -8, 1919, 1088)),
] {
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected));
assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed");
}
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar");
// A taskbar that reserves its work area is the clamp's job.
assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None);
assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen");
}
#[test]
fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() {
let monitor = (0, 0, 1920, 1080);
assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown");
assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden");
assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top));
assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left));
assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right));
assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor");
assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow");
}
}
}
#[cfg(not(target_os = "windows"))]
mod platform {
use designcraft_ui_egui::WindowFrameFn;
pub fn service(_cc: &eframe::CreationContext<'_>, _custom_titlebar: bool) -> Option<WindowFrameFn> {
None
}
}
+12
View File
@@ -63,11 +63,23 @@ real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.macrobi]
real_name = "Veress Róbert"
display_name = "Veress Róbert"
sync_github_name = false
[people.4waymedia]
real_name = "Paul Gemignani"
[people.skylartaylor]
display_name = "skylartaylor"
[people.y-az]
real_name = "Youssef Azriouil"
display_name = "youssef"
[people.dylanmtaylor]
sync_github_name = true
[people.Tom-R-Main]
sync_github_name = true
+8
View File
@@ -118,6 +118,8 @@ impl Color {
} else {
60.0 * ((r - g) / d + 4.0)
};
// A tiny negative `rem_euclid` input can round up to exactly 6.0 (hue 360).
let h = if h >= 360.0 { 0.0 } else { h };
let s = if max <= 0.0 { 0.0 } else { d / max };
[h, s, max]
}
@@ -251,6 +253,12 @@ impl Paint {
mod tests {
use super::*;
#[test]
fn hsb_hue_stays_below_360() {
let h = Color::rgb(1.0, 0.0, 1e-8).to_hsb()[0];
assert!((0.0..360.0).contains(&h), "hue {h}");
}
#[test]
fn hex_roundtrip() {
let c = Color::from_hex("#ff8000").unwrap();
+1 -1
View File
@@ -21,7 +21,7 @@ log = { workspace = true }
regex = { workspace = true }
fancy-regex = { workspace = true }
miniz_oxide = "0.8"
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "french", "german", "greek", "italian", "portuguese", "spanish", "norwegian"] }
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "dutch", "french", "german", "greek", "italian", "portuguese", "spanish", "norwegian"] }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }
+23 -1
View File
@@ -3,7 +3,7 @@
//! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J.
//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause; Norwegian is
//! `hyph-no.tex` from hyph-utf8 (Rune Kleveland, Ole Michael Selberg), permissive (see NOTICE);
//! Greek is `hyph-el-monoton.tex` 5.0 from hyph-utf8 (Dimitrios Filippou), LPPL or MIT, taken
//! Dutch is Piet Tutelaers's `hyph-nl.tex` 1.1 from hyph-utf8, MIT; Greek is `hyph-el-monoton.tex` 5.0 from hyph-utf8 (Dimitrios Filippou), LPPL or MIT, taken
//! under MIT. Those are the **monotonic** patterns, which is what `hypher` maps `el` to, so
//! polytonic text hyphenates by them as well. French is `hyph-fr.tex` 2.13 from hyph-utf8 (Daniel
//! Flipo, Bernard Gaulle, Arthur Reutenauer), MIT; Italian is `hyph-it.tex` 4.9 from hyph-utf8
@@ -588,6 +588,28 @@ mod tests {
assert_eq!(marked("dell'impaginazione", it), "dell'im-pa-gi-na-zio-ne", "after an elision");
}
#[test]
fn dutch_uses_the_hyph_nl_patterns() {
let nl = Lang::for_language("Dutch").unwrap();
// InDesign writes the 2005 spelling as `nl_NL_2005`.
assert_eq!(nl, Lang::for_language("nl_NL_2005").unwrap());
assert_ne!(nl, Lang::for_language("Spanish").unwrap());
let h = |w: &str| {
let pts = hyphen_points_in(w, &Limits::default(), nl);
w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::<String>()
};
assert_eq!(h("uitspraak"), "uit-spraak");
assert_eq!(h("kinderen"), "kin-de-ren");
assert_eq!(h("leesvaardigheid"), "lees-vaar-dig-heid");
assert_eq!(h("zelfvertrouwen"), "zelf-ver-trou-wen");
assert_eq!(h("Ontwikkelen"), "Ont-wik-ke-len");
// Long compounds break at their parts and at the syllables inside them.
assert_eq!(h("arbeidsongeschiktheidsverzekering"), "ar-beids-on-ge-schikt-heids-ver-ze-ke-ring");
assert_eq!(h("ziekenhuisopname"), "zie-ken-huis-op-na-me");
assert_eq!(h("gemeenteraadsverkiezingen"), "ge-meen-te-raads-ver-kie-zin-gen");
assert_eq!(h("kinderdagverblijf"), "kin-der-dag-ver-blijf");
}
#[test]
fn heuristic_fallback() {
let c: Vec<char> = "happen".chars().collect();
+10
View File
@@ -27,6 +27,16 @@ pub fn with_document_extension(path: &str) -> String {
format!("{path}.{}", designcraft_format::EXTENSION)
}
/// `name` without a trailing `.designcraft` (any case): the title of a document opened from bytes
/// under its file name. Save, Save As and the File Name variable add the extension back, so a title
/// that kept it would name the file `Brochure.designcraft.designcraft`. A bare `.designcraft` stays.
pub(super) fn without_native_extension(name: &str) -> &str {
match name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() && ext.eq_ignore_ascii_case(designcraft_format::EXTENSION) => stem,
_ => name,
}
}
/// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package.
/// The first [`DOCUMENT_SNIFF_BYTES`] of a file are enough for packages that start with their
/// `mimetype` entry, as both formats write them.
+38 -2
View File
@@ -1,6 +1,6 @@
#[cfg(test)]
use super::bytes::{PREVIEW_PX, to_bytes};
use super::bytes::{base64_decode, load_bytes};
use super::bytes::{base64_decode, load_bytes, without_native_extension};
use super::{Arc, DocState, Document, EngineError, Result, Session, Value, bad, json, str_param};
pub(super) fn has_recent(s: &Session) -> std::result::Result<(), String> {
@@ -150,7 +150,8 @@ pub(super) fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let (mut d, warnings) = load_bytes(&b)?;
crate::cmd::datamerge::resolve_sources_on_open(&mut d, None);
if let Some(n) = str_param(p, "name") {
d.title = n.to_string();
// Titled as an IDML file opened from bytes is: without its extension.
d.title = without_native_extension(n).to_string();
}
let i = s.add_document(DocState::new(d, None));
Ok(json!({"index": i, "warnings": warnings}))
@@ -413,6 +414,41 @@ mod document_fonts_tests {
}
}
#[cfg(test)]
mod open_bytes_tests {
use serde_json::json;
use crate::Session;
/// A `.designcraft` file opened from bytes under its file name (a browser or host passes the
/// name it has) is titled without the extension, as an IDML file is, so saving it gives
/// `Brochure.designcraft` and not `Brochure.designcraft.designcraft`.
#[test]
fn open_bytes_titles_a_native_document_without_its_extension() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let native = s.execute("file.serialize", &json!({})).unwrap();
for (name, title) in [
("Brochure.designcraft", "Brochure"),
("Brochure.DesignCraft", "Brochure"),
("Brochure", "Brochure"),
("Brochure.v2", "Brochure.v2"),
("Brochure.designcraft.designcraft", "Brochure.designcraft"),
(".designcraft", ".designcraft"),
// Names that aren't ASCII: cutting the extension's length off the end of the last one
// would land inside its `é`.
("Präsentation.designcraft", "Präsentation"),
("日本.DESIGNCRAFT", "日本"),
("é", "é"),
("édesigncraft", "édesigncraft"),
] {
let r = s.execute("file.openBytes", &json!({"name": name, "base64": native["base64"]})).unwrap();
let st = &s.documents()[r["index"].as_u64().unwrap() as usize];
assert_eq!((st.doc.title.as_str(), st.title()), (title, title.to_string()), "{name}");
}
}
}
#[cfg(test)]
mod recent_tests {
use serde_json::json;
+71 -1
View File
@@ -14,7 +14,7 @@ use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle, replacementFamily, replacementStyle}] (missing first)", has_doc, |s, _| {
cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle, replacementFamily, replacementStyle, fsType, restrictedLicence, bitmapOnly, noSubsetting, editable, previewPrint (the OS/2 fsType embedding bits of the face that sets the text and what they mean; null when missing or the face has no OS/2 table)}] (missing first)", has_doc, |s, _| {
Ok(Value::Array(list(&s.doc()?.doc)))
}),
cmd!(
@@ -75,6 +75,19 @@ pub(super) struct UsedFont {
pub resolved_style: String,
pub replacement_family: String,
pub replacement_style: String,
/// The OS/2 `fsType` of the face that sets the text (`resolved_*`); None when missing or the
/// face has no OS/2 table (as are the bits below).
pub fs_type: Option<u16>,
/// Restricted License embedding (bit 1 without bits 2–3).
pub restricted_licence: Option<bool>,
/// Bitmap embedding only (bit 9).
pub bitmap_only: Option<bool>,
/// No subsetting (bit 8): the font may only be embedded whole.
pub no_subsetting: Option<bool>,
/// Editable embedding (bit 3).
pub editable: Option<bool>,
/// Preview & Print embedding (bit 2).
pub preview_print: Option<bool>,
}
fn list(d: &Document) -> Vec<Value> {
@@ -124,6 +137,8 @@ pub(super) fn used_fonts(d: &Document) -> Vec<UsedFont> {
let resolved = db.face(&family, &style);
let characters: Vec<char> = characters.into_iter().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
let replacement = db.replacement_face(&family, &style, &characters);
let fs_type = (!missing).then(|| resolved.fs_type()).flatten();
let bit = |mask: u16| fs_type.map(|t| t & mask != 0);
let source = (!missing).then(|| match db.face(&family, &style).source {
FontSource::Bundled => "bundled",
FontSource::Installed(_) => "installed",
@@ -148,6 +163,12 @@ pub(super) fn used_fonts(d: &Document) -> Vec<UsedFont> {
resolved_style: resolved.style.clone(),
replacement_family: replacement.family.clone(),
replacement_style: replacement.style.clone(),
fs_type,
restricted_licence: fs_type.map(|_| resolved.restricted_licence()),
bitmap_only: bit(0x0200),
no_subsetting: bit(0x0100),
editable: bit(0x0008),
preview_print: bit(0x0004),
}
})
.collect();
@@ -297,6 +318,55 @@ mod tests {
assert_eq!(before, s.execute("font.list", &json!({})).unwrap());
}
/// `font.list` reports each face's OS/2 fsType and what its bits allow, so a caller can
/// refuse a face whose licence doesn't allow the export it is about to make.
#[test]
fn font_list_reports_the_embedding_licence_bits() {
use designcraft_fonts::testing::{font_with, with_fs_type, with_table_len};
const FAMILY: &str = "DocFont List FsType";
const FS_TYPES: [u16; 3] = [0x0002, 0x0108, 0x0204];
for fs_type in FS_TYPES {
designcraft_fonts::FontDb::global()
.add_font(with_fs_type(font_with(&format!("{FAMILY} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap());
}
// An OS/2 table cut to nothing can't be read: as if the font had none.
designcraft_fonts::FontDb::global().add_font(with_table_len(font_with(&format!("{FAMILY} no OS/2"), &['W']).unwrap(), b"OS/2", 0).unwrap());
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// "W W W W Plain Gone": each W in one of the fonts above, Plain in the default (bundled)
// font, Gone in a family that isn't available.
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "W W W W Plain Gone"})).unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
let set = |s: &mut Session, at: usize, end: usize, family: &str| {
s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": end})).unwrap();
s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap();
};
for (i, fs_type) in FS_TYPES.into_iter().enumerate() {
set(&mut s, i * 2, i * 2 + 1, &format!("{FAMILY} {fs_type:04x}"));
}
set(&mut s, 6, 7, &format!("{FAMILY} no OS/2"));
set(&mut s, 14, 18, "Nonexistent Sans");
let l = s.execute("font.list", &json!({})).unwrap();
let rows = l.as_array().unwrap();
let entry = |family: &str| rows.iter().find(|f| f["family"] == family).unwrap_or_else(|| panic!("{family}: {l}"));
let bits = |e: &Value| ["fsType", "restrictedLicence", "bitmapOnly", "noSubsetting", "editable", "previewPrint"].map(|k| e[k].clone());
let (t, f) = (json!(true), json!(false));
assert_eq!(entry(&format!("{FAMILY} 0002"))["source"], "added", "{l}");
assert_eq!(bits(entry(&format!("{FAMILY} 0002"))), [json!(0x0002), t.clone(), f.clone(), f.clone(), f.clone(), f.clone()], "{l}");
assert_eq!(bits(entry(&format!("{FAMILY} 0108"))), [json!(0x0108), f.clone(), f.clone(), t.clone(), t.clone(), f.clone()], "{l}");
assert_eq!(bits(entry(&format!("{FAMILY} 0204"))), [json!(0x0204), f.clone(), t.clone(), f.clone(), f.clone(), t.clone()], "{l}");
// The bundled fonts are OFL: installable embedding, every bit clear.
let bundled = rows.iter().find(|r| r["source"] == "bundled").unwrap_or_else(|| panic!("no bundled face: {l}"));
assert_eq!(bits(bundled), [json!(0), f.clone(), f.clone(), f.clone(), f.clone(), f], "{l}");
// A face without an OS/2 table doesn't say what its licence allows.
assert_eq!(entry(&format!("{FAMILY} no OS/2"))["missing"], false, "{l}");
assert_eq!(bits(entry(&format!("{FAMILY} no OS/2"))), [(); 6].map(|()| Value::Null), "{l}");
// A missing face has no file to read.
let missing = entry("Nonexistent Sans");
assert_eq!(missing["missing"], true, "{l}");
assert_eq!(bits(missing), [(); 6].map(|()| Value::Null), "{l}");
}
#[test]
fn find_and_replace_font() {
let mut s = Session::new();
+25 -2
View File
@@ -35,6 +35,11 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des
it
}
/// The `size` parameter in points: 12 by default (also for non-finite values), clamped to 2..=400.
fn size_param(p: &Value) -> f64 {
p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(12.0).clamp(2.0, 400.0)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -48,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(query "math.svg", "Math Expression SVG", [], None, "{latex, size?} → {svg, width, height} — typeset without placing", has_doc, |_s, p| {
let latex = str_param(p, "latex").ok_or_else(|| bad("math.svg", "`latex` required"))?;
let (svg, (w, h)) = crate::math::to_svg(latex, p.get("size").and_then(Value::as_f64).unwrap_or(12.0));
let (svg, (w, h)) = crate::math::to_svg(latex, size_param(p));
Ok(json!({"svg": String::from_utf8_lossy(&svg), "width": w, "height": h}))
}),
]
@@ -56,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let latex = str_param(p, "latex").ok_or_else(|| bad("math.insert", "`latex` required"))?.to_string();
let size = p.get("size").and_then(Value::as_f64).unwrap_or(12.0).clamp(2.0, 400.0);
let size = size_param(p);
let lid = s.doc()?.active_layer;
// Edit an existing expression in place.
if let Some(id) = super::id_param(p, "id") {
@@ -102,6 +107,24 @@ mod tests {
use crate::Session;
#[test]
fn math_svg_clamps_size_like_insert() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let dims = |s: &mut Session, size: f64| {
let r = s.execute("math.svg", &json!({"latex": "x", "size": size})).unwrap();
(r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap())
};
let (w2, h2) = dims(&mut s, 2.0);
let (w400, h400) = dims(&mut s, 400.0);
for size in [0.0, -5.0, 1e-300] {
assert_eq!(dims(&mut s, size), (w2, h2), "size {size}");
}
for size in [1e9, 1e300] {
assert_eq!(dims(&mut s, size), (w400, h400), "size {size}");
}
}
#[test]
fn math_places_inline_and_as_frames() {
let mut s = Session::new();
+7 -1
View File
@@ -900,7 +900,13 @@ fn layout(f: &Fonts, n: &Node, size: f64) -> MBox {
pub fn to_svg(latex: &str, size: f64) -> (Vec<u8>, (f64, f64)) {
let db = FontDb::global();
let f = Fonts { upright: db.face(FAMILY, "Regular"), italic: db.face(FAMILY, "Italic"), fallback: db.face(FALLBACK, "Regular") };
let b = layout(&f, &parse(latex), size);
// XML 1.0 forbids most control characters and U+FFFE/U+FFFF; drop them before layout so
// they take no width either.
let latex: String = latex
.chars()
.filter(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}'))
.collect();
let b = layout(&f, &parse(&latex), size);
let pad = size * 0.1;
let (w, h) = (b.w + 2.0 * pad, b.asc + b.desc + 2.0 * pad);
let base = pad + b.asc;
+9
View File
@@ -474,3 +474,12 @@ fn deep_nodes_drop_when_replaced_or_owned_by_unwinding_continuations() {
});
assert!(result.is_err(), "the intentional panic must actually unwind the owned frames");
}
#[test]
fn svg_drops_control_characters_forbidden_by_xml() {
let (svg, _) = to_svg("a\u{1}b\u{c}", 12.0);
let svg = String::from_utf8(svg).unwrap();
assert!(!svg.chars().any(|c| c < ' ' && !matches!(c, '\t' | '\n' | '\r')), "control character in SVG: {svg:?}");
// The dropped characters take no width either.
assert_eq!(to_svg("a\u{1}b\u{c}", 12.0).1, to_svg("ab", 12.0).1);
}
+10 -2
View File
@@ -26,7 +26,8 @@ pub struct Step {
pub fn parse(text: &str) -> Result<Vec<Step>, String> {
// Windows tools save "UTF-8 with BOM"; `trim` keeps U+FEFF, which then became part of the
// first command id.
let t = text.trim_start_matches('\u{feff}').trim();
let body = text.trim_start_matches('\u{feff}');
let t = body.trim();
if t.starts_with('[') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?;
return v
@@ -38,7 +39,8 @@ pub fn parse(text: &str) -> Result<Vec<Step>, String> {
.collect();
}
let mut out = Vec::new();
for (n, line) in t.lines().enumerate() {
// Number lines from the untrimmed text so leading blank lines still count.
for (n, line) in body.lines().enumerate() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') || l.starts_with("//") {
continue;
@@ -185,6 +187,12 @@ impl crate::Session {
mod tests {
use super::*;
#[test]
fn leading_blank_lines_keep_line_numbers() {
let e = parse("\n\n# c\nfile.new\nframe.create {bad").unwrap_err();
assert!(e.contains("line 5"), "{e}");
}
#[test]
fn parses_all_forms() {
let a = parse(r#"[{"command": "file.new"}, {"command": "frame.create", "params": {"rect": [0, 0, 10, 10]}}]"#).unwrap();
+3 -3
View File
@@ -45,13 +45,13 @@ pub fn reading_order(doc: &Document) -> Vec<Block> {
continue;
};
let xf = *parent * it.xf;
if it.hidden {
continue;
}
let children = it.children();
if !children.is_empty() {
stack.push((children.iter(), xf));
}
if it.hidden {
continue;
}
let block = match &it.content {
Content::Text(tf) => {
let Some(story) = doc.story(tf.story) else { continue };
+18
View File
@@ -977,3 +977,21 @@ fn override_keeps_style_decorations_and_resets_tracking_and_caps() {
assert_eq!(css::override_css(&d, "Base", &small), "text-transform: none; font-variant: normal; font-variant: small-caps;");
assert_eq!(css::override_css(&d, "Small", &all), "text-transform: none; font-variant: normal; text-transform: uppercase;");
}
fn grouped_text(hidden: bool) -> Document {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Grouped text", ParaFormat::default()).unwrap();
let child = Arc::make_mut(&mut d.spreads[0]).items.pop().unwrap();
let mut g = Item::new(ItemId(d.alloc()), lid, Shape::Group, Default::default());
g.content = Content::Group { items: vec![child] };
g.hidden = hidden;
Arc::make_mut(&mut d.spreads[0]).items.push(Arc::new(g));
d
}
#[test]
fn hidden_group_children_are_not_exported() {
assert!(html::reading_order(&grouped_text(true)).is_empty());
assert_eq!(html::reading_order(&grouped_text(false)).len(), 1);
}
+8 -3
View File
@@ -297,16 +297,21 @@ impl FontFace {
/// (bit 1) without a less restrictive bit (Preview & Print, Editable). Fonts without an OS/2
/// table aren't restricted.
pub fn restricted_licence(&self) -> bool {
use skrifa::raw::TableProvider;
let fs_type = self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type());
let fs_type = self.fs_type().unwrap_or(0);
fs_type & 0x0002 != 0 && fs_type & 0x000C == 0
}
/// Does the font's licence allow embedding bitmaps only? OS/2 `fsType` bit 9: the outlines
/// may not be passed on, so the font file can't be packaged whole.
pub fn bitmap_only(&self) -> bool {
self.fs_type().is_some_and(|t| t & 0x0200 != 0)
}
/// The font's OS/2 `fsType` (embedding licence bits), None when the font has no OS/2 table it
/// can read: the font doesn't say, which isn't the same as 0 (installable).
pub fn fs_type(&self) -> Option<u16> {
use skrifa::raw::TableProvider;
self.skrifa().and_then(|f| f.os2().ok()).is_some_and(|t| t.fs_type() & 0x0200 != 0)
self.skrifa().and_then(|f| f.os2().ok()).map(|t| t.fs_type())
}
}
+10 -1
View File
@@ -27,7 +27,8 @@ pub fn stroke_contains(path: &BezPath, width: f64, tol: f64, p: Point) -> bool {
/// Does the path intersect (or lie inside) the rect? Used for marquee selection.
pub fn intersects_rect(path: &PathData, r: Rect) -> bool {
let Some(b) = path.bounds() else { return false };
if b.intersect(r).area() <= 0.0 && !(b.width() == 0.0 || b.height() == 0.0) {
let flat = |q: Rect| q.width() == 0.0 || q.height() == 0.0;
if b.intersect(r).area() <= 0.0 && !flat(b) && !flat(r) {
return false;
}
// Any anchor inside?
@@ -90,4 +91,12 @@ mod tests {
let l = shapes::line(Point::new(0.0, 0.0), Point::new(10.0, 10.0));
assert!(intersects_rect(&l, Rect::new(4.0, 0.0, 6.0, 10.0)));
}
#[test]
fn flat_marquee() {
let p = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
assert!(intersects_rect(&p, Rect::new(-5.0, 5.0, 15.0, 5.0)));
assert!(intersects_rect(&p, Rect::new(5.0, -5.0, 5.0, 15.0)));
assert!(!intersects_rect(&p, Rect::new(-5.0, 20.0, 15.0, 20.0)));
}
}
+44 -3
View File
@@ -2,6 +2,20 @@
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathSeg, Point};
/// Direction at a curve end whose derivative vanishes: from the end anchor towards the nearest
/// control point that differs from it (`at_end` selects the last anchor).
fn first_distinct(pts: &[Point], at_end: bool) -> kurbo::Vec2 {
let n = pts.len();
for k in 1..n {
let (a, b) = if at_end { (pts[n - 1 - k], pts[n - 1]) } else { (pts[0], pts[k]) };
let v = b - a;
if v.hypot2() > 1e-18 {
return v;
}
}
kurbo::Vec2::ZERO
}
/// A path measured for placing glyphs along it.
#[derive(Clone, Debug)]
pub struct PathWarp {
@@ -29,12 +43,24 @@ impl PathWarp {
let n = self.segs.len();
for (i, (seg, len)) in self.segs.iter().enumerate() {
if s <= *len || i + 1 == n {
let t = if *len > 1e-9 { seg.inv_arclen(s.min(*len), 1e-4) } else { 0.0 };
let t = if *len <= 1e-9 || s <= 0.0 {
0.0
} else if s >= *len {
1.0
} else {
seg.inv_arclen(s, 1e-4)
};
let p = seg.eval(t);
let d = match seg {
PathSeg::Line(l) => l.p1 - l.p0,
PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(),
PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(),
PathSeg::Quad(q) => {
let d = q.deriv().eval(t).to_vec2();
if d.hypot2() > 1e-18 { d } else { first_distinct(&[q.p0, q.p1, q.p2], t > 0.5) }
}
PathSeg::Cubic(c) => {
let d = c.deriv().eval(t).to_vec2();
if d.hypot2() > 1e-18 { d } else { first_distinct(&[c.p0, c.p1, c.p2, c.p3], t > 0.5) }
}
};
let mut a = d.y.atan2(d.x);
if self.flip {
@@ -98,4 +124,19 @@ mod tests {
assert!((w.nearest(Point::new(30.0, -8.0)) - 30.0).abs() < 1e-6);
assert!((flipped.nearest(Point::new(30.0, -8.0)) - 170.0).abs() < 1e-6);
}
#[test]
fn collapsed_handles_fall_back_to_the_next_control_point() {
let half_pi = std::f64::consts::FRAC_PI_2;
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.curve_to((0.0, 0.0), (0.0, 10.0), (10.0, 10.0));
let w = PathWarp::new(&bp, false);
assert!((w.at(0.0).unwrap().1 - half_pi).abs() < 1e-9, "start handle on the anchor");
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.curve_to((10.0, 0.0), (10.0, 10.0), (10.0, 10.0));
let w = PathWarp::new(&bp, false);
assert!((w.at(w.length()).unwrap().1 - half_pi).abs() < 1e-9, "end handle on the anchor");
}
}
+1
View File
@@ -14,6 +14,7 @@ designcraft-fonts = { workspace = true }
image = { workspace = true }
tiff = { workspace = true }
resvg = { workspace = true }
skrifa = { workspace = true }
quick-xml = { workspace = true }
flate2 = "1"
log = { workspace = true }
+64 -14
View File
@@ -104,7 +104,10 @@ fn svg_options() -> &'static usvg::Options<'static> {
/// family's but a face's PostScript name (as Illustrator's SVG export writes them:
/// `font-family="'MyriadPro-Regular'"`) sets the text in that face, whatever font-weight and
/// font-style say: the name already picks the weight and style, and a family's other faces would
/// contradict it. Families listed before it come first.
/// contradict it. A name that is a face's legacy family name (name ID 1) or full name (ID 4), as
/// Windows names faces outside the regular/bold/italic quartet (`'Segoe UI Semibold'`, whose
/// typographic family is `Segoe UI`), sets the text in that face (the closest to font-weight and
/// font-style when the legacy family has several). Families listed before it come first.
fn select_font() -> usvg::FontSelectionFn<'static> {
use usvg::fontdb::{Family, Query, Stretch, Style, Weight};
Box::new(move |font, db| {
@@ -112,27 +115,67 @@ fn select_font() -> usvg::FontSelectionFn<'static> {
let spelled = |db: &usvg::fontdb::Database, name: &str| {
db.faces().flat_map(|f| &f.families).find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(n, _)| n.clone())
};
for family in font.families() {
// (index in the font-family list, face) of the faces a name names by its legacy names.
let mut legacy: Vec<(usize, usvg::fontdb::ID)> = Vec::new();
for (i, family) in font.families().iter().enumerate() {
if let usvg::FontFamily::Named(name) = family
&& spelled(db, name).is_none()
{
if fonts.has_family(name) {
for style in fonts.styles(name) {
add_face(db, fonts.face(name, &style));
let face = fonts.face(name, &style);
let named = has_legacy_name(&face, name);
if let Some(id) = add_face(db, face)
&& named
{
legacy.push((i, id));
}
}
} else if postscript_face(db, name).is_none() {
if let Some((family, style)) = fonts.postscript_face(name) {
add_face(db, fonts.face(&family, &style));
} else {
// A full name: the family is the name without its last words ("Bold").
let mut base = name.as_str();
for _ in 0..3 {
let Some((head, _)) = base.trim_end().rsplit_once(' ') else { break };
base = head;
if fonts.has_family(base) {
for style in fonts.styles(base) {
let face = fonts.face(base, &style);
if has_legacy_name(&face, name)
&& let Some(id) = add_face(db, face)
{
legacy.push((i, id));
}
}
break;
}
}
}
} else if postscript_face(db, name).is_none()
&& let Some((family, style)) = fonts.postscript_face(name)
{
add_face(db, fonts.face(&family, &style));
}
}
}
for family in font.families() {
let want_style = match font.style() {
usvg::FontStyle::Normal => Style::Normal,
usvg::FontStyle::Italic => Style::Italic,
usvg::FontStyle::Oblique => Style::Oblique,
};
for (i, family) in font.families().iter().enumerate() {
match family {
usvg::FontFamily::Named(name) if spelled(db, name).is_none() => {
if let Some(id) = postscript_face(db, name) {
return Some(id);
}
let closest = legacy
.iter()
.filter(|(at, _)| *at == i)
.filter_map(|(_, id)| db.face(*id))
.min_by_key(|f| (f.style != want_style, f.weight.0.abs_diff(font.weight())))
.map(|f| f.id);
if closest.is_some() {
return closest;
}
}
// A family (or generic family) that matches: the query below.
_ => break,
@@ -173,15 +216,22 @@ fn select_font() -> usvg::FontSelectionFn<'static> {
usvg::FontStretch::ExtraExpanded => Stretch::ExtraExpanded,
usvg::FontStretch::UltraExpanded => Stretch::UltraExpanded,
};
let style = match font.style() {
usvg::FontStyle::Normal => Style::Normal,
usvg::FontStyle::Italic => Style::Italic,
usvg::FontStyle::Oblique => Style::Oblique,
};
db.query(&Query { families: &families, weight: Weight(font.weight()), stretch, style })
db.query(&Query { families: &families, weight: Weight(font.weight()), stretch, style: want_style })
})
}
/// Is `name` (ignoring case) a legacy family name (name ID 1) or full name (ID 4) of `face`?
fn has_legacy_name(face: &designcraft_fonts::FontFace, name: &str) -> bool {
use skrifa::MetadataProvider;
use skrifa::string::StringId;
let Some(f) = face.skrifa() else { return false };
[StringId::FAMILY_NAME, StringId::FULL_NAME]
.into_iter()
.flat_map(|id| f.localized_strings(id))
.take(256)
.any(|s| s.chars().map(|c| c.to_ascii_lowercase()).eq(name.chars().map(|c| c.to_ascii_lowercase())))
}
/// The face in the SVG's font database with PostScript name `name` (ignoring case).
fn postscript_face(db: &usvg::fontdb::Database, name: &str) -> Option<usvg::fontdb::ID> {
db.faces().find(|f| f.post_script_name.eq_ignore_ascii_case(name)).map(|f| f.id)
+28
View File
@@ -248,6 +248,34 @@ fn svg_text_finds_fonts_by_postscript_name() {
assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular");
}
#[test]
fn svg_text_matches_legacy_family_names() {
use designcraft_fonts::testing::{font_with_names, with_table_u16};
// Laid out as Windows names faces outside the regular/bold/italic quartet (seguisb.ttf): the
// weight is in the legacy family (ID 1) and full name (ID 4), the typographic family (ID 16)
// is the base name.
let face = |names: &[(u16, &str)], weight: u16| with_table_u16(font_with_names(names, &['a', 'b']).unwrap(), b"OS/2", 4, weight).unwrap();
let fonts = designcraft_fonts::FontDb::global();
fonts.add_font(face(&[(1, "DC Test Legacy"), (2, "Regular"), (4, "DC Test Legacy Regular"), (6, "DCTestLegacy-Regular")], 400));
fonts.add_font(face(&[(1, "DC Test Legacy"), (2, "Bold"), (4, "DC Test Legacy Bold"), (6, "DCTestLegacy-Bold")], 700));
for (name, weight) in [("Semibold", 600), ("Light", 300)] {
let legacy = format!("DC Test Legacy {name}");
let ps = format!("DCTestLegacy-{name}");
fonts.add_font(face(&[(1, &legacy), (2, "Regular"), (4, &legacy), (6, &ps), (16, "DC Test Legacy"), (17, name)], weight));
}
assert_eq!(text_face(r#" font-family="DC Test Legacy Semibold""#), "DCTestLegacy-Semibold");
assert_eq!(text_face(r#" font-family="dc test legacy light""#), "DCTestLegacy-Light");
assert_eq!(text_face(r#" font-family="'DC Test Legacy Semibold', sans-serif""#), "DCTestLegacy-Semibold");
assert_eq!(text_face(r#" font-family="NoSuch Font, 'DC Test Legacy Light', serif""#), "DCTestLegacy-Light");
// The full name (ID 4) of a face of the quartet.
assert_eq!(text_face(r#" font-family="DC Test Legacy Bold""#), "DCTestLegacy-Bold");
// The typographic family still picks by weight.
assert_eq!(text_face(r#" font-family="DC Test Legacy" font-weight="600""#), "DCTestLegacy-Semibold");
assert_eq!(text_face(r#" font-family="DC Test Legacy""#), "DCTestLegacy-Regular");
// A name nobody has still falls to the serif.
assert_eq!(text_face(r#" font-family="DC Test Legacy Nothing""#), "SourceSerif4-Regular");
}
/// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image.
fn tiny_psd() -> Vec<u8> {
let mut b = Vec::new();
+1 -1
View File
@@ -51,7 +51,7 @@ mod transitions;
pub use export::{
BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs, print_color, sheet_spreads,
};
pub use pdfx::{check_pdfx4, has_rgb_groups, make_pdfx4};
pub use pdfx::{check_pdfx4, has_rgb_groups, make_pdfx4, pdfx_version};
pub use pdfx1a::check_pdfx1a;
pub use transitions::{add_blend_space, add_catalog_entries, add_page_entries, add_transitions};
+1 -1
View File
@@ -58,7 +58,7 @@ fn edited(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])]) -> Result<Vec<u8>> {
/// The trailer of the cross-reference section at `offset`: a table's `trailer` dictionary or an
/// xref stream's own. A table is read entry by entry rather than searched for the keyword, so a
/// `trailer` in a comment can't stand in for the real one; reading is bounded either way.
fn trailer_at(bytes: &[u8], offset: usize) -> Option<Dict<'_>> {
pub(crate) fn trailer_at(bytes: &[u8], offset: usize) -> Option<Dict<'_>> {
const MAX_SECTION_BYTES: usize = 4 * 1024 * 1024;
let data = bytes.get(offset..)?;
let mut reader = Reader::new(data.get(..data.len().min(MAX_SECTION_BYTES))?);
+56 -14
View File
@@ -8,6 +8,7 @@ use std::collections::HashSet;
use hayro_syntax::content::UntypedIter;
use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, Stream};
use hayro_syntax::page::Resources;
use hayro_syntax::reader::ReaderExt;
use craft_pdf::write::WriteError;
use craft_pdf::write::backend as krilla;
@@ -115,16 +116,18 @@ pub fn has_rgb_groups(pdf: &[u8]) -> bool {
pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
let mut issues = Vec::new();
let text = String::from_utf8_lossy(pdf);
let file = hayro_syntax::Pdf::new(pdf.to_vec()).ok();
if !pdf.starts_with(b"%PDF-1.") || pdf.get(7).is_none_or(|v| *v > b'6') {
issues.push("the PDF version must be 1.6 or lower".into());
}
if !text.contains("/S/GTS_PDFX") {
let intents = file.as_ref().map(pdfx_intents).unwrap_or_default();
if intents.is_empty() {
issues.push("no PDF/X output intent".into());
}
if !text.contains("/DestOutputProfile") {
if !intents.iter().any(|oi| oi.contains_key(b"DestOutputProfile")) {
issues.push("the output intent has no destination profile".into());
}
if !text.contains("/GTS_PDFXVersion(PDF/X-4)") {
if file.as_ref().and_then(|f| info_text(pdf, f, b"GTS_PDFXVersion")).as_deref() != Some("PDF/X-4") {
issues.push("the document information doesn't name PDF/X-4".into());
}
if !text.contains("pdfxid:GTS_PDFXVersion>PDF/X-4<") {
@@ -150,22 +153,67 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
if text.contains("/Subtype/Type3") {
issues.push("Type 3 fonts aren't allowed".into());
}
issues.extend(device_rgb(pdf));
issues.extend(device_rgb(file.as_ref()));
issues.extend(fonts::unembedded_fonts(pdf));
issues
}
/// The document information's `GTS_PDFXVersion` (`PDF/X-4`, `PDF/X-1:2003`, …), read as an
/// object; `None` when the file can't be read or doesn't name one.
pub fn pdfx_version(pdf: &[u8]) -> Option<String> {
info_text(pdf, &hayro_syntax::Pdf::new(pdf.to_vec()).ok()?, b"GTS_PDFXVersion")
}
/// The catalog's `GTS_PDFX` output intents, read as objects (whitespace and object streams
/// don't matter).
pub(crate) fn pdfx_intents<'a>(file: &'a hayro_syntax::Pdf) -> Vec<Dict<'a>> {
let xref = file.xref();
let Some(catalog) = xref.get::<Dict<'_>>(xref.root_id()) else { return Vec::new() };
let Some(intents) = catalog.get::<Array<'_>>(b"OutputIntents") else { return Vec::new() };
intents.iter::<Dict<'_>>().filter(|oi| oi.get::<Name<'_>>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX")).collect()
}
/// The text entry `key` of the document information dictionary, trimmed: the `/Info` of the
/// newest trailer that has one (classic or cross-reference stream, following `/Prev` a bounded
/// number of times), else of the last `trailer` keyword in the file.
pub(crate) fn info_text(pdf: &[u8], file: &hayro_syntax::Pdf, key: &[u8]) -> Option<String> {
let lookup = |trailer: &Dict<'_>| -> Option<String> {
let info = match trailer.get_ref(b"Info") {
Some(r) => file.xref().get::<Dict<'_>>(r.into())?,
None => trailer.get::<Dict<'_>>(b"Info")?,
};
let value = info.get::<hayro_syntax::object::String<'_>>(key)?;
Some(String::from_utf8_lossy(value.as_bytes()).trim().to_string())
};
let tail = pdf.get(rfind(pdf, b"startxref")?..).unwrap_or_default();
let mut offset = int_after(&String::from_utf8_lossy(tail), "startxref");
let mut seen = HashSet::new();
for _ in 0..32 {
let Some(at) = offset.filter(|at| seen.insert(*at)) else { break };
let Some(trailer) = crate::optional_content::trailer_at(pdf, at) else { break };
if trailer.contains_key(b"Info") {
return lookup(&trailer);
}
offset = trailer.get::<usize>(b"Prev");
}
// A damaged cross-reference offset: the dictionary after the last `trailer` keyword.
let at = rfind(pdf, b"trailer")? + b"trailer".len();
let mut reader = hayro_syntax::reader::Reader::new(pdf.get(at..)?);
reader.skip_white_spaces_and_comments();
lookup(&reader.read_without_context::<Dict<'_>>()?)
}
/// Device-dependent RGB under a CMYK output intent. PDF/X-4 (ISO 15930-7) lets RGB in only when
/// it is colour-managed: ICCBased RGB, or DeviceRGB remapped by a `DefaultRGB` colour space in the
/// resources. DeviceCMYK and DeviceGray are the output intent's own device values, so they stay.
/// This walks every page's content, the images, forms, patterns, shadings and soft masks it uses,
/// and the transparency groups, and reports each kind of device RGB it finds with the pages it is
/// on. Nothing is reported when the file has no CMYK PDF/X output intent.
fn device_rgb(pdf: &[u8]) -> Vec<String> {
let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else {
fn device_rgb(file: Option<&hayro_syntax::Pdf>) -> Vec<String> {
let Some(file) = file else {
return vec!["the file couldn't be read to check its colour spaces".into()];
};
if !cmyk_output_intent(&file) {
if !cmyk_output_intent(file) {
return Vec::new();
}
let mut found: Vec<(String, Vec<usize>)> = Vec::new();
@@ -198,13 +246,7 @@ fn device_rgb(pdf: &[u8]) -> Vec<String> {
/// Whether the file's PDF/X output intent has a four-channel (CMYK) destination profile.
pub(crate) fn cmyk_output_intent(file: &hayro_syntax::Pdf) -> bool {
let xref = file.xref();
let Some(catalog) = xref.get::<Dict<'_>>(xref.root_id()) else { return false };
let Some(intents) = catalog.get::<Array<'_>>(b"OutputIntents") else { return false };
intents.iter::<Dict<'_>>().any(|oi| {
oi.get::<Name<'_>>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX")
&& oi.get::<Stream<'_>>(b"DestOutputProfile").and_then(|p| p.dict().get::<i32>(b"N")) == Some(4)
})
pdfx_intents(file).iter().any(|oi| oi.get::<Stream<'_>>(b"DestOutputProfile").and_then(|p| p.dict().get::<i32>(b"N")) == Some(4))
}
/// Whether a colour space is device RGB: `/DeviceRGB` (`/RGB` in inline images), or an
+103
View File
@@ -462,3 +462,106 @@ fn xmp_states_trapped() {
assert!(String::from_utf8_lossy(&r.bytes).contains("/Trapped/False"));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// `objects` (from 1) as a PDF 1.6 file with a classic cross-reference table and `trailer`.
fn assemble_x4(objects: &[&str], trailer: &str) -> Vec<u8> {
let mut out = b"%PDF-1.6\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes());
for off in offsets {
out.extend_from_slice(format!("{off:010} 00000 n\r\n").as_bytes());
}
out.extend_from_slice(format!("trailer\n{trailer}\nstartxref\n{xref}\n%%EOF\n").as_bytes());
out
}
const NO_INTENT: &str = "no PDF/X output intent";
const NO_INFO: &str = "the document information doesn't name PDF/X-4";
/// The output intent and the document information are read as objects: whitespace between
/// tokens (as qpdf and Ghostscript write them) doesn't hide them.
#[test]
fn x4_checks_ignore_whitespace_between_tokens() {
let pdf = assemble_x4(
&[
"<< /Type /Catalog /Pages 2 0 R /OutputIntents [ << /Type /OutputIntent /S /GTS_PDFX /DestOutputProfile 4 0 R >> ] >>",
"<< /Type /Pages /Kids [ 3 0 R ] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 100 100 ] /TrimBox [ 0 0 100 100 ] >>",
"<< /N 4 /Length 0 >>\nstream\n\nendstream",
"<< /GTS_PDFXVersion (PDF/X-4) /Trapped /False >>",
],
"<< /Size 6 /Root 1 0 R /Info 5 0 R >>",
);
let issues = check_pdfx4(&pdf);
assert!(!issues.iter().any(|i| i == NO_INTENT || i == NO_INFO), "{issues:?}");
// Without them, both are still reported.
let bare = assemble_x4(
&[
"<< /Type /Catalog /Pages 2 0 R /OutputIntents [ << /Type /OutputIntent /S /GTS_PDFA1 >> ] >>",
"<< /Type /Pages /Kids [ 3 0 R ] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 100 100 ] /TrimBox [ 0 0 100 100 ] >>",
"<< /N 4 /Length 0 >>\nstream\n\nendstream",
"<< /Title (/S/GTS_PDFX /GTS_PDFXVersion(PDF/X-4)) /GTS_PDFXVersion (PDF/X-3:2002) >>",
],
"<< /Size 6 /Root 1 0 R /Info 5 0 R >>",
);
let issues = check_pdfx4(&bare);
assert!(issues.iter().any(|i| i == NO_INTENT) && issues.iter().any(|i| i == NO_INFO), "{issues:?}");
}
/// The output intent and the document information are found inside a compressed object stream,
/// through a cross-reference stream.
#[test]
fn x4_checks_read_object_streams() {
let plain = [
"<</Type/Catalog/Pages 2 0 R/OutputIntents[7 0 R]>>",
"<</Type/Pages/Kids[3 0 R]/Count 1>>",
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/TrimBox[0 0 100 100]>>",
"<</N 4/Length 0>>\nstream\n\nendstream",
];
let (info, intent) = ("<</GTS_PDFXVersion(PDF/X-4)/Trapped/False>>", "<</Type/OutputIntent/S/GTS_PDFX/DestOutputProfile 4 0 R>>");
let mut out = b"%PDF-1.6\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in plain.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
// Object 5 is free; 6 and 7 live in object stream 8.
let header = format!("6 0 7 {} ", info.len() + 1);
let body = format!("{header}{info} {intent}");
let objstm = out.len();
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
std::io::Write::write_all(&mut z, body.as_bytes()).unwrap();
let packed = z.finish().unwrap();
out.extend_from_slice(
format!("8 0 obj\n<</Type/ObjStm/N 2/First {}/Filter/FlateDecode/Length {}>>\nstream\n", header.len(), packed.len()).as_bytes(),
);
out.extend_from_slice(&packed);
out.extend_from_slice(b"\nendstream\nendobj\n");
let xref = out.len();
let mut rows = Vec::new();
let mut row = |kind: u8, field: usize, idx: u16| {
rows.push(kind);
rows.extend_from_slice(&u32::try_from(field).unwrap().to_be_bytes());
rows.extend_from_slice(&idx.to_be_bytes());
};
row(0, 0, 65535);
for off in &offsets {
row(1, *off, 0);
}
row(0, 0, 0);
row(2, 8, 0);
row(2, 8, 1);
row(1, objstm, 0);
row(1, xref, 0);
out.extend_from_slice(format!("9 0 obj\n<</Type/XRef/Size 10/W[1 4 2]/Root 1 0 R/Info 6 0 R/Length {}>>\nstream\n", rows.len()).as_bytes());
out.extend_from_slice(&rows);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes());
let issues = check_pdfx4(&out);
assert!(!issues.iter().any(|i| i == NO_INTENT || i == NO_INFO), "{issues:?}");
}
+11 -13
View File
@@ -116,16 +116,18 @@ pub fn check_pdfx1a(pdf: &[u8]) -> Vec<String> {
fn checks(pdf: &[u8]) -> (Vec<String>, Vec<String>) {
let mut issues = Vec::new();
let text = String::from_utf8_lossy(pdf);
let file = hayro_syntax::Pdf::new(pdf.to_vec()).ok();
if !pdf.starts_with(b"%PDF-1.") || pdf.get(7).is_none_or(|v| *v > b'4') {
issues.push("the PDF version must be 1.4 or lower".into());
}
if !text.contains("/S/GTS_PDFX") {
if file.as_ref().is_none_or(|f| crate::pdfx::pdfx_intents(f).is_empty()) {
issues.push("no PDF/X output intent".into());
}
if !text.contains(&format!("/GTS_PDFXVersion({VERSION})")) {
let info = |key: &[u8]| file.as_ref().and_then(|f| crate::pdfx::info_text(pdf, f, key));
if info(b"GTS_PDFXVersion").as_deref() != Some(VERSION) {
issues.push(format!("the document information doesn't name {VERSION}"));
}
if !text.contains(&format!("/GTS_PDFXConformance({CONFORMANCE})")) {
if info(b"GTS_PDFXConformance").as_deref() != Some(CONFORMANCE) {
issues.push(format!("the document information doesn't name the conformance level {CONFORMANCE}"));
}
if !text.contains("/Trapped/False") && !text.contains("/Trapped/True") && !text.contains("/Trapped /False") && !text.contains("/Trapped /True") {
@@ -142,18 +144,18 @@ fn checks(pdf: &[u8]) -> (Vec<String>, Vec<String>) {
if pages > 0 && trims < pages {
issues.push("every page needs a trim box".into());
}
let (content, fonts) = content(pdf);
let (content, fonts) = content(file.as_ref());
issues.extend(fonts);
(content, issues)
}
/// Transparency and color spaces, then the output intent and fonts, walked through every page's
/// content and what it uses.
fn content(pdf: &[u8]) -> (Vec<String>, Vec<String>) {
let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else {
fn content(file: Option<&hayro_syntax::Pdf>) -> (Vec<String>, Vec<String>) {
let Some(file) = file else {
return (vec!["the file couldn't be read to check its transparency and color spaces".into()], Vec::new());
};
let mut fonts = intent_issues(&file);
let mut fonts = intent_issues(file);
let mut found: Vec<(String, Vec<usize>)> = Vec::new();
let mut font_found: Vec<(String, Vec<usize>)> = Vec::new();
for (i, page) in file.pages().iter().enumerate() {
@@ -214,13 +216,9 @@ impl Intent {
/// The catalog's `GTS_PDFX` output intents.
fn pdfx_intents(file: &hayro_syntax::Pdf) -> Vec<Intent> {
let xref = file.xref();
let Some(catalog) = xref.get::<Dict<'_>>(xref.root_id()) else { return Vec::new() };
let Some(intents) = catalog.get::<Array<'_>>(b"OutputIntents") else { return Vec::new() };
let number = |n: i32| usize::try_from(n).ok();
intents
.iter::<Dict<'_>>()
.filter(|oi| oi.get::<Name<'_>>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX"))
crate::pdfx::pdfx_intents(file)
.into_iter()
.map(|oi| {
let text = |key: &[u8]| {
oi.get::<hayro_syntax::object::String<'_>>(key)
+10
View File
@@ -318,3 +318,13 @@ fn x1a_dropped_inks_warning_says_they_wont_print() {
assert!(warnings.iter().any(|w| w.contains("3 tint(s)") && w.contains("dropped and won't print")), "{warnings:?}");
assert!(warnings.iter().all(|w| !w.contains("converted to process")), "{warnings:?}");
}
/// The output intent and the identification are read as objects: whitespace between tokens (as
/// qpdf and Ghostscript write them) doesn't hide them.
#[test]
fn x1a_checks_ignore_whitespace_between_tokens() {
let mut objects = tiny_objects("/Resources<<>>", "0 0 0 1 k", "/OutputConditionIdentifier(Custom)/DestOutputProfile 4 0 R", &icc_header(2));
objects[5] = b"<< /GTS_PDFXVersion (PDF/X-1:2003) /GTS_PDFXConformance (PDF/X-1a:2003) /Trapped /False >>".to_vec();
objects[6] = b"<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Custom) /DestOutputProfile 4 0 R >>".to_vec();
assert_eq!(check_pdfx1a(&assemble(&objects)), Vec::<String>::new());
}
+10 -2
View File
@@ -15,7 +15,7 @@ fn plain_char(c: char) -> Option<&'static str> {
match c {
PAGE_NUMBER | NEXT_PAGE_NUMBER | PREV_PAGE_NUMBER => Some("#"),
FORCED_LINE_BREAK => Some("\u{2028}"),
'\u{E000}'..='\u{E0FF}' => None,
'\u{E000}'..='\u{E1FF}' => None,
_ => Some(""),
}
}
@@ -174,7 +174,7 @@ pub fn rtf(doc: &Document, story: &Story) -> String {
}
}
PAGE_NUMBER | NEXT_PAGE_NUMBER | PREV_PAGE_NUMBER => out.push('#'),
'\u{E000}'..='\u{E0FF}' => {}
'\u{E000}'..='\u{E1FF}' => {}
c => rtf_escape(&mut out, c),
}
}
@@ -201,6 +201,14 @@ mod tests {
st
}
#[test]
fn text_variable_markers_are_dropped() {
let st = Story::with_text(StoryId(1), "Page \u{E100} of", Default::default());
assert_eq!(plain_text(&st), "Page of");
let r = rtf(&Document::new(&NewDocument::default()), &st);
assert!(!r.contains("\\u-7936") && r.contains("Page of"), "{r}");
}
#[test]
fn text_only() {
assert_eq!(plain_text(&story()), "Plain bold {x}\r\nCafé\ntwo");
+30 -3
View File
@@ -161,8 +161,7 @@ impl DesignApp {
} else if place {
self.open_file("file.place", json!({"name": file.name, "base64": b64}))
} else {
let title = file.name.rsplit_once('.').map_or(file.name.as_str(), |(stem, _)| stem);
self.open_file("file.openBytes", json!({"name": title, "base64": b64}))
self.open_file("file.openBytes", json!({"name": file.name, "base64": b64}))
}
}
@@ -377,7 +376,7 @@ mod import_warning_tests {
let request = app.import_request("open");
app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: "partial.idml".into(), bytes: idml(true) });
app.drain_inbox();
assert_partial_import_warning(&mut app, "partial");
assert_partial_import_warning(&mut app, "partial.idml");
}
#[test]
@@ -424,6 +423,34 @@ pub(crate) mod browser_import_tests {
app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: name.into(), bytes });
app.drain_inbox();
}
/// A document chosen or dropped in the browser is titled by its file name less one extension,
/// as on the desktop: the extension comes off once, in the engine.
#[test]
fn browser_opens_title_a_document_by_its_name_without_one_extension() {
let mut app = app();
let native = designcraft_engine::cmd::base64_decode(app.run("file.serialize", json!({})).unwrap()["base64"].as_str().unwrap());
for purpose in ["open", "drop"] {
for (name, title) in [
("Brochure.designcraft", "Brochure"),
("Brochure.designcraft.designcraft", "Brochure.designcraft"),
("Brochure.v2.designcraft", "Brochure.v2"),
("日本.DESIGNCRAFT", "日本"),
(".designcraft", ".designcraft"),
("Brochure", "Brochure"),
] {
let request = app.import_request(purpose);
deliver(&mut app, request, name, native.clone());
assert_eq!(app.session.doc().unwrap().doc.title, title, "{purpose}: {name}");
}
}
let idml = designcraft_idml::export_idml(&app.session.doc().unwrap().doc);
for purpose in ["open", "drop"] {
let request = app.import_request(purpose);
deliver(&mut app, request, "Brochure.idml", idml.clone());
assert_eq!(app.session.doc().unwrap().doc.title, "Brochure", "{purpose}: IDML");
}
}
fn image() -> Vec<u8> {
br##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#ff0000"/></svg>"##.to_vec()
}
+5
View File
@@ -23,6 +23,11 @@ impl DesignApp {
/// Inject synthetic events (one pointer event per host iteration), retaining control
/// provenance until a settled UI pass has actually taken the batch.
pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// An undecorated window gets its caption back when the toolkit changes its flags
// (maximize, restore, …): take it off again before the frame is laid out (#1295).
if let Some(window_frame) = self.services.window_frame.as_mut() {
window_frame();
}
if !self.injected_settle_hook {
let pending = self.injected_pending.clone();
let settled = self.injected_settled.clone();
+74 -1
View File
@@ -12,12 +12,14 @@ mod ar;
mod cs;
mod de;
mod fr;
mod hu;
mod it;
mod ja;
mod ka;
mod pl;
mod pt_br;
mod ru;
mod sv;
mod uk;
/// Supported interface languages: (code, name in that language).
@@ -36,6 +38,8 @@ pub const LANGUAGES: &[(&str, &str)] = &[
("cs", "Čeština"),
("pl", "Polski"),
("ka", "ქართული"),
("hu", "Magyar"),
("sv", "Svenska"),
];
/// The interface languages in the order the Preferences picker lists them: English first (the
@@ -77,6 +81,8 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
if text.trim().is_empty() { s } else { text }
}
/// Looks up a label in lazily cached language tables, using the shared table where supported.
/// Unknown languages and missing keys keep the source text; `tr` also handles blank entries.
fn lookup<'a>(lang: &str, s: &'a str) -> &'a str {
static GERMAN: OnceLock<HashMap<&str, &str>> = OnceLock::new();
static JAPANESE: OnceLock<HashMap<&str, &str>> = OnceLock::new();
@@ -90,6 +96,11 @@ fn lookup<'a>(lang: &str, s: &'a str) -> &'a str {
static CZECH: OnceLock<HashMap<&str, &str>> = OnceLock::new();
static FRENCH: OnceLock<HashMap<&str, &str>> = OnceLock::new();
static GEORGIAN: OnceLock<HashMap<&str, &str>> = OnceLock::new();
static HUNGARIAN: OnceLock<HashMap<&str, &str>> = OnceLock::new();
if lang == "hu" {
return HUNGARIAN.get_or_init(|| hu::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
}
static SWEDISH: OnceLock<HashMap<&str, &str>> = OnceLock::new();
if lang == "ar" {
return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
}
@@ -123,6 +134,9 @@ fn lookup<'a>(lang: &str, s: &'a str) -> &'a str {
if lang == "ka" {
return GEORGIAN.get_or_init(|| ka::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
}
if lang == "sv" {
return SWEDISH.get_or_init(|| sv::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
}
if lang == "uk" {
return UKRAINIAN.get_or_init(|| uk::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
}
@@ -145,12 +159,14 @@ const CONTEXT: &[(&str, &str, [&str; 5])] = &[
/// Context-sensitive UI captions; unsupported contexts keep the shared translation.
pub fn tr_context<'a>(lang: &str, s: &'a str, context: &str) -> &'a str {
let contexts = match lang {
"hu" => hu::CONTEXT,
"uk" => uk::CONTEXT,
"ru" => ru::CONTEXT,
"cs" => cs::CONTEXT,
"pl" => pl::CONTEXT,
"ja" => ja::CONTEXT,
"ar" => ar::CONTEXT,
"sv" => sv::CONTEXT,
_ => &[],
};
let written = |text: &&str| !text.trim().is_empty();
@@ -176,6 +192,12 @@ pub(crate) fn russian_catalog() -> Vec<&'static str> {
ru::TABLE.iter().map(|(_, text)| *text).chain(ru::CONTEXT.iter().map(|(_, _, text)| *text)).collect()
}
/// Whether the Swedish catalog has an entry for `s` (many Swedish labels read like the English ones).
#[cfg(test)]
pub(crate) fn swedish_has(s: &str) -> bool {
sv::TABLE.iter().any(|(key, _)| *key == s)
}
/// Ukrainian, Russian, Polish and Georgian put the category before the value in count captions
/// ("сторінок: 5", "страниц: 5", "Strony: 5", "გვერდი: 5"): every number stays grammatical in the
/// languages with several plural forms, and Georgian nouns stay singular after numerals. Other
@@ -184,12 +206,29 @@ pub fn category_first(lang: &str) -> bool {
matches!(lang, "uk" | "ru" | "pl" | "ka")
}
/// Swedish singular for a count of one, where it differs from the catalog's plural ("1 sida",
/// "5 sidor"); "fel", "objekt" and "lager" read the same either way.
fn swedish_singular(noun: &str) -> Option<&'static str> {
match noun {
"pages" => Some("sida"),
"stories" => Some("artikel"),
"warnings" => Some("varning"),
_ => None,
}
}
/// Caption for a counted category, e.g. `count_label("ru", "pages", 5)` is "страниц: 5". Czech
/// picks the plural form for the count (1, 2–4, other).
/// picks the plural form for the count (1, 2–4, other); Swedish the singular for one.
pub fn count_label(lang: &str, noun: &str, count: usize) -> String {
if category_first(lang) {
return format!("{}: {count}", tr(lang, noun));
}
if lang == "sv"
&& count == 1
&& let Some(one) = swedish_singular(noun)
{
return format!("{count} {one}");
}
if lang == "cs"
&& let Some((_, [one, few, other])) = cs::PLURALS.iter().find(|(key, _)| *key == noun)
{
@@ -231,6 +270,40 @@ pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod swedish_tests {
use super::*;
#[test]
fn swedish_catalog_preserves_placeholders_and_ellipses() {
let catalog: HashMap<_, _> = sv::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), sv::TABLE.len(), "duplicate Swedish keys");
let placeholders = |s: &str| {
s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<std::collections::BTreeSet<_>>()
};
for (key, translation) in sv::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}");
assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
assert_eq!(tr("sv", key), *translation, "sv: {key}");
}
let contexts: std::collections::HashSet<_> = sv::CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect();
assert_eq!(contexts.len(), sv::CONTEXT.len(), "duplicate Swedish context keys");
for (key, context, expected) in sv::CONTEXT {
assert_eq!(placeholders(key), placeholders(expected), "placeholders: {key} ({context})");
assert_eq!(tr_context("sv", key, context), *expected, "sv {context}: {key}");
}
assert_eq!(tr("sv", "File"), "Arkiv");
assert_eq!(tr("sv", "Save As…"), "Spara som…");
assert_eq!(tr("sv", "Unknown label"), "Unknown label");
assert!(!is_rtl("sv"));
assert_eq!(count_label("sv", "pages", 5), "5 sidor");
assert_eq!(count_label("sv", "pages", 1), "1 sida");
assert_eq!(count_label("sv", "warnings", 1), "1 varning");
assert_eq!(count_label("sv", "errors", 1), "1 fel");
}
}
/// `name` lowercased with the Latin accents that occur in language names folded away, so a
/// byte-wise sort reads alphabetically. Other scripts are left as they are.
fn name_sort_key(name: &str) -> String {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+224 -2
View File
@@ -7,18 +7,28 @@ const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[
("cs", cs::TABLE),
("de", de::TABLE),
("fr", fr::TABLE),
("hu", hu::TABLE),
("it", it::TABLE),
("ja", ja::TABLE),
("ka", ka::TABLE),
("pl", pl::TABLE),
("pt-br", pt_br::TABLE),
("ru", ru::TABLE),
("sv", sv::TABLE),
("uk", uk::TABLE),
];
/// Every per-language table `tr_context` looks up.
const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] =
&[("ar", ar::CONTEXT), ("cs", cs::CONTEXT), ("ja", ja::CONTEXT), ("pl", pl::CONTEXT), ("ru", ru::CONTEXT), ("uk", uk::CONTEXT)];
const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] = &[
("ar", ar::CONTEXT),
("cs", cs::CONTEXT),
("hu", hu::CONTEXT),
("ja", ja::CONTEXT),
("pl", pl::CONTEXT),
("ru", ru::CONTEXT),
("sv", sv::CONTEXT),
("uk", uk::CONTEXT),
];
/// The languages of the shared tables' columns, in column order.
const SHARED_COLUMNS: [&str; 5] = ["de", "fr", "es", "ja", "zh"];
@@ -31,6 +41,7 @@ fn or_english<'a>(text: &'a str, english: &'a str) -> &'a str {
if text.trim().is_empty() { english } else { text }
}
/// Checks cached catalog lookup across language switches, including source-text fallback.
#[test]
fn cached_lookup_preserves_every_translation_across_language_switches() {
let own = |lang: &str, key: &str| {
@@ -61,6 +72,167 @@ fn cached_lookup_preserves_every_translation_across_language_switches() {
}
}
/// Checks Hungarian catalog integrity, context captions and count substitutions, including
/// English fallback and preservation of user-defined style and workspace names.
#[test]
fn hungarian_catalog_preserves_placeholders_context_and_counts() {
let catalog: HashMap<_, _> = hu::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), hu::TABLE.len(), "duplicate Hungarian keys");
let placeholders = |s: &str| {
let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect();
names.sort();
names
};
for (key, translation) in hu::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}");
assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
}
assert!(!is_rtl("hu"));
assert_eq!(tr("hu", "File"), "Fájl");
assert_eq!(tr("hu", "Bleed"), "Kifutó");
assert_eq!(tr("hu", "Arrangement"), "Elrendezés");
assert_eq!(tr("hu", "Unknown label"), "Unknown label");
assert_eq!(tr("hu", "Columns"), "Hasábok");
assert_eq!(tr_context("hu", "Columns", "table"), "Oszlopok");
assert_eq!(tr("hu", "Columns:"), "Hasábok:");
assert_eq!(tr_context("hu", "Columns:", "grid"), "Oszlopok:");
assert_eq!(tr_context("hu", "Columns:", "unknown"), "Hasábok:");
assert_eq!(tr("hu", "Group"), "Csoportosítás");
assert_eq!(tr_context("hu", "Group", "selection"), "Csoport");
assert_eq!(tr_context("hu", "Columns", "unknown"), "Hasábok");
assert_eq!(tr_context("", "Columns", "table"), "Columns");
for n in [0, 1, 2, 5, 21] {
assert_eq!(count_label("hu", "pages", n), format!("{n} oldal"));
assert_eq!(count_label("hu", "objects", n), format!("{n} objektum"));
assert_eq!(count_label("hu", "errors", n), format!("{n} hiba"));
assert_eq!(
tr("hu", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string()),
format!("{n} oldal, {n} oldalpáron")
);
}
assert_eq!(style_name("hu", "[Basic Paragraph]"), "[Alapbekezdés]");
assert_eq!(style_name("hu", "Saját stílus"), "Saját stílus");
assert_eq!(workspace_name("hu", "Essentials"), "Alapok");
assert_eq!(workspace_name("hu", "Saját munkaterület"), "Saját munkaterület");
}
/// Checks every Latin catalog letter and all Hungarian accented vowels against the
/// configured bundled UI faces, with system and optional craft-fonts fallbacks disabled.
#[test]
fn hungarian_catalog_glyphs_exist_in_the_bundled_ui_fonts() {
let definitions = crate::theme::font_definitions(&[], "hu");
let db = designcraft_fonts::FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
let letters: std::collections::BTreeSet<_> = hu::TABLE
.iter()
.flat_map(|(_, text)| text.chars())
.chain(hu::CONTEXT.iter().flat_map(|(_, _, text)| text.chars()))
.chain("ÁáÉéÍíÓóÖöŐőÚúÜüŰű".chars())
.filter(|c| c.is_alphabetic() && *c <= '\u{024f}')
.collect();
for (name, family, style, ui_family) in [
("ui", "Source Sans 3", "Regular", egui::FontFamily::Proportional),
("ui-semibold", "Source Sans 3", "Semibold", egui::FontFamily::Name("semibold".into())),
("mono", "JetBrains Mono", "Regular", egui::FontFamily::Monospace),
] {
assert_eq!(definitions.families[&ui_family].first().map(String::as_str), Some(name));
let face = db.face(family, style);
assert_eq!(face.data(), definitions.font_data[name].bytes(), "configured UI face {name}");
for letter in &letters {
assert_ne!(face.glyph_for(*letter), 0, "missing {letter} in {family} {style}");
}
}
eprintln!(
"Hungarian glyph coverage: {} Latin catalog letters, including ŐőŰű, in Source Sans 3 Regular/Semibold and JetBrains Mono Regular; no system/craft fallback",
letters.len()
);
if let Some(dir) = std::env::var_os("DESIGNCRAFT_HU_REVIEW_SHOTS") {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(820.0, 260.0)).build_ui_state(
|ui, ready: &mut bool| {
if !*ready {
ui.ctx().set_fonts(crate::theme::font_definitions(&[], "hu"));
*ready = true;
return;
}
ui.style_mut().visuals = egui::Visuals::light();
ui.painter().rect_filled(ui.ctx().content_rect(), 0.0, egui::Color32::WHITE);
ui.heading("Magyar ékezetek · ő, ű / Ő, Ű");
for (name, family) in [
("Source Sans 3 Regular", egui::FontFamily::Proportional),
("Source Sans 3 Semibold", egui::FontFamily::Name("semibold".into())),
("JetBrains Mono Regular", egui::FontFamily::Monospace),
] {
ui.label(egui::RichText::new(name).color(egui::Color32::DARK_GRAY));
ui.label(
egui::RichText::new("Árvíztűrő tükörfúrógép · Őő Űű · ÁÉÍÓÖŐÚÜŰ")
.font(egui::FontId::new(22.0, family))
.color(egui::Color32::BLACK),
);
}
},
false,
);
h.run_steps(3);
h.render().unwrap().save(std::path::Path::new(&dir).join("hungarian-bundled-ui-fonts.png")).unwrap();
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.language = "hu".into();
app.run("file.new", serde_json::json!({"pages": 1})).unwrap();
app.run(
"frame.create",
serde_json::json!({"rect": [60, 60, 420, 180], "content": "text", "text": "Magyar kiadvány
Árvíztűrő tükörfúrógép
Őő Űű · ÁÉÍÓÖŐÚÜŰ"}),
)
.unwrap();
app.run("type.char", serde_json::json!({"attrs": {"fontFamily": "Source Sans 3", "size": 28}})).unwrap();
app.run("window.panel.activate", serde_json::json!({"panel": "character"})).unwrap();
let mut interface = crate::test_window::open(app, egui::vec2(1400.0, 900.0));
interface.render().unwrap().save(std::path::Path::new(&dir).join("designcraft-hungarian-interface.png")).unwrap();
}
}
/// Checks translated labels and source-text fallback, plus key uniqueness and nonempty rows
/// in the shared, Arabic, Brazilian Portuguese and Italian catalogs.
#[test]
fn translates_known_strings_and_keeps_the_rest() {
assert_eq!(tr("de", "File"), "Datei");
assert_eq!(tr("ja", "Swatches"), "スウォッチ");
assert_eq!(tr("fr", "Something new"), "Something new");
assert_eq!(tr("", "File"), "File");
assert_eq!(tr("ar", "File"), "ملف");
assert_eq!(tr("ar", "Unknown label"), "Unknown label");
assert_eq!(tr("pt-br", "File"), "Arquivo");
assert_eq!(tr("pt-br", "New Document…"), "Novo documento…");
assert_eq!(tr("pt-br", "Unknown label"), "Unknown label");
assert_eq!(tr("it", "Edit"), "Modifica");
assert_eq!(tr("it", "New Document…"), "Nuovo documento…");
assert_eq!(tr("it", "Unknown label"), "Unknown label");
assert!(is_rtl("ar"));
for (i, (en, translation)) in ar::TABLE.iter().enumerate() {
assert!(ar::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
assert!(!translation.is_empty(), "{en}");
}
for (i, (en, translation)) in pt_br::TABLE.iter().enumerate() {
assert!(pt_br::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
assert!(!translation.is_empty(), "{en}");
}
for (i, (en, translation)) in it::TABLE.iter().enumerate() {
assert!(it::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}");
assert!(!translation.is_empty(), "{en}");
}
// Italian entries are for keys the shared or Arabic table knows. A new English string
// falls back to English until it is translated, so it doesn't fail here.
for (en, _) in it::TABLE {
assert!(TABLE.iter().any(|(key, _)| key == en) || ar::TABLE.iter().any(|(key, _)| key == en), "it: unknown key {en}");
}
// Every row is unique and complete.
for (i, (en, t)) in TABLE.iter().enumerate() {
assert!(TABLE[..i].iter().all(|(e, _)| e != en), "duplicate {en}");
assert!(t.iter().all(|x| !x.is_empty()), "{en}");
}
}
/// The Data Merge arrangement combo takes its accessible name from `tr(lang, "Arrangement")`.
/// A row in only the shared table leaves the languages whose lookup returns directly with the
/// English name.
@@ -77,6 +249,7 @@ fn arrangement_accessible_name_is_localized_in_every_interface_language() {
}
}
/// Checks language-specific context precedence and fallback to the plain translation.
#[test]
fn context_captions_come_from_their_context_or_the_plain_translation() {
for (lang, table) in CONTEXT_TABLES {
@@ -110,6 +283,38 @@ fn context_captions_come_from_their_context_or_the_plain_translation() {
}
}
/// Keeps quantity labels distinct from numeric values and checks translated Pages-panel captions.
#[test]
fn a_column_count_and_a_list_number_read_differently() {
for (lang, count, numeral) in [
("hu", "Darabszám:", "Szám:"),
("de", "Anzahl:", "Zahl:"),
("ja", "数:", "番号:"),
("uk", "Кількість:", "Номер:"),
("ar", "العدد:", "الرقم:"),
("fr", "Nombre :", "Numéro:"),
("zh", "数量:", "编号:"),
] {
assert_eq!((tr_context(lang, "Number:", "count"), tr(lang, "Number:")), (count, numeral), "{lang}");
}
assert_eq!(tr_context("en", "Number:", "count"), "Number:");
assert_eq!(tr("de", "Gutter:"), "Spaltenabstand:");
// The Pages panel menu.
for key in [
"Insert Pages…",
"Move Pages…",
"New Parent…",
"Override All Parent Page Items",
"Remove All Local Overrides",
"Detach All Objects from Parent",
"Create Alternate Layout…",
"Allow Selected Spread to Shuffle",
] {
assert_ne!(tr("pt-br", key), key);
}
assert_ne!(tr("ar", "Apply Parent to Pages"), "Apply Parent to Pages");
}
/// The tables `tr` reads, as (language, key, translation), context captions included.
fn entries() -> Vec<(&'static str, &'static str, &'static str)> {
let mut entries: Vec<_> = LANGUAGE_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, text)| (*lang, *key, *text))).collect();
@@ -263,6 +468,23 @@ fn interface_strings() -> HashSet<&'static str> {
keys.extend(crate::panel_docking::DOCK_LABELS.iter().map(|(_, english)| *english));
// UI labels passed to `tr` that no other catalog has yet.
keys.extend([
"180°",
"Create Links When Placing Text Files",
"Delete Character Style…",
"Delete Paragraph Style…",
"Delete the character style \"{name}\"?",
"Delete the paragraph style \"{name}\"?",
"IT",
"Replace with:",
"Show the Typography panel",
"Stroke Behind Fill",
"Stylistic Set",
"The selected fonts have no OpenType features.",
"This story was edited in the layout: you'll be asked before those edits are replaced",
"This style is in use. Text using it will be reassigned to:",
"VA",
"Y:",
"“{}” has been edited in this layout. Updating replaces those edits with the text from the file.",
"(any)",
"(unchanged)",
"[No Language]",
+4 -1
View File
@@ -71,7 +71,7 @@ use app::browser_import_tests;
pub use app::opens_as_document;
pub use clock::now_ms;
pub use key_names::{drop_key_name_text, is_key_name};
pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, ReadFn, Services, WriteFn};
pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, ReadFn, Services, WindowFrameFn, WriteFn};
pub(crate) use state::second_window_viewport;
pub use state::{CanvasCache, HiddenPanels, ImportRequest, ImportedFile, PdfExportSettings, Perf, SavedWorkspace, ScreenMode, Shown, UiState, View};
@@ -314,6 +314,9 @@ pub fn cjk_features(app: &DesignApp) -> bool {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod window_frame_tests;
/// The whole window in a headless test harness, at a chosen window size.
#[cfg(test)]
pub(crate) mod test_window {
+34
View File
@@ -65,3 +65,37 @@ fn cmd_item(id: &str, params: Value) -> Item {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod swedish_tests {
use super::*;
#[test]
fn swedish_interface_is_selectable_and_translates_every_menu_label() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
run_ui(&mut app, "app.language", &json!({"lang": "sv"})).unwrap().unwrap();
assert_eq!(app.ui.language, "sv");
assert_eq!(checked(&app, "app.language", &json!({"lang": "sv"})), Some(true));
assert_eq!(crate::i18n::tr("sv", "File"), "Arkiv");
// Every menu title, item and submenu has a Swedish entry.
let mut untranslated = Vec::new();
for (title, entries) in menu_tree() {
let mut labels = vec![title.to_string()];
let mut stack: Vec<&Item> = entries.iter().collect();
while let Some(entry) = stack.pop() {
match entry {
Item::Sub(label, children) => {
labels.push(label.clone());
stack.extend(children.iter());
}
Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()),
_ => {}
}
}
// Swedish shares many words with English ("Layout", "Cyan"), so look for a catalog entry.
let size = |l: &str| l.strip_suffix(" pt").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
untranslated.extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && !size(l) && !crate::i18n::swedish_has(l)));
}
assert!(untranslated.is_empty(), "untranslated Swedish menu labels: {untranslated:?}");
}
}
+15
View File
@@ -151,9 +151,24 @@ pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, param
if params.is_null() && app.session.wants_text() && matches!(id, "edit.copy" | "edit.cut" | "edit.paste") && forward_to_text_edit(app, ctx, id) {
return;
}
// Zoom In/Out by key equivalent: the pointer is still in the window. A click in the menu bar
// takes it out, so that zooms around the centre.
if params.is_null()
&& let Some(p) = pointer_zoom_params(ctx, id)
{
activate(app, id, &p);
return;
}
activate(app, id, params);
}
/// Zoom In/Out from a key: the pointer's position (`at`), so the zoom keeps the point under it
/// when it is over the canvas. `None` for other commands or with no pointer in the window.
pub(super) fn pointer_zoom_params(ctx: &egui::Context, id: &str) -> Option<Value> {
let at = ctx.pointer_hover_pos()?;
matches!(id, "view.zoomIn" | "view.zoomOut").then(|| json!({"at": [at.x, at.y]}))
}
/// The editing commands a focused text field takes from the native menu bar.
pub(super) const TEXT_FIELD_COMMANDS: [&str; 6] = ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"];
+9 -5
View File
@@ -1,4 +1,4 @@
use super::invoke::activate;
use super::invoke::{activate, pointer_zoom_params};
use super::{DesignApp, ScreenMode, Value, json};
/// The command a paste event stands for besides plain Paste. egui-winit turns every ⌘+V chord into
@@ -39,7 +39,7 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
}
let typing = app.session.wants_text();
let keys = ctx.input_mut(|i| take_keys(app, i, typing));
run_keys(app, keys, typing);
run_keys(app, ctx, keys, typing);
}
/// Keys that a floating panel in its own OS window left over (craft-ui's
@@ -59,7 +59,7 @@ pub fn forwarded_shortcuts(app: &mut DesignApp, ctx: &egui::Context, events: Vec
input.events = events;
input.modifiers = modifiers;
let keys = take_keys(app, &mut input, typing);
run_keys(app, keys, typing);
run_keys(app, ctx, keys, typing);
crate::canvas::forwarded_keys(app, input.events, modifiers);
}
@@ -112,7 +112,7 @@ fn take_keys(app: &mut DesignApp, input: &mut egui::InputState, typing: bool) ->
}
/// Runs what [`take_keys`] took, then Esc's return to the Selection tool.
fn run_keys(app: &mut DesignApp, keys: Keys, typing: bool) {
fn run_keys(app: &mut DesignApp, ctx: &egui::Context, keys: Keys, typing: bool) {
for step in keys.presentation {
match step {
None => {
@@ -138,7 +138,11 @@ fn run_keys(app: &mut DesignApp, keys: Keys, typing: bool) {
app.select_tool(&tool);
} else {
// Like choosing the menu item: "…" commands open their dialog.
activate(app, &hit.command.id, &hit.command.params);
// Zoom In/Out by key zoom around the pointer while it is over the canvas.
match pointer_zoom_params(ctx, &hit.command.id) {
Some(p) => activate(app, &hit.command.id, &p),
None => activate(app, &hit.command.id, &hit.command.params),
}
}
}
// Esc with no shortcut of its own goes back to the Selection tool.
+8 -1
View File
@@ -579,7 +579,14 @@ fn run_ui_part3(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
Some(match id {
"view.zoomIn" | "view.zoomOut" => {
if let Some(r) = rect {
crate::canvas::zoom_at(app, r.center(), if id == "view.zoomIn" { 1.5 } else { 1.0 / 1.5 });
// A keyboard zoom names the pointer ("at"); it counts only over the canvas.
let at = p
.get("at")
.and_then(Value::as_array)
.filter(|a| a.len() == 2)
.and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)));
let at = at.filter(|at| r.contains(*at)).unwrap_or_else(|| r.center());
crate::canvas::zoom_at(app, at, if id == "view.zoomIn" { 1.5 } else { 1.0 / 1.5 });
}
Ok(Value::Null)
}
+196
View File
@@ -154,6 +154,7 @@ fn define_lists_command_path_matches_the_type_menu() {
assert!(menu_ids("Type").contains(&"list.define"));
}
/// Checks that screen-mode menu checkmarks follow the current Normal or Preview mode.
#[test]
fn screen_mode_shows_the_current_mode() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -163,6 +164,24 @@ fn screen_mode_shows_the_current_mode() {
assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true)));
}
/// Checks that translated customization paths match the visible submenu captions, while
/// allowing uncatalogued paths to keep their English fallback.
#[test]
fn hungarian_menu_paths_match_the_visible_submenu_labels() {
// These paths are displayed by menu customization; they must use the same
// captions as the actual menu tree, including nested special characters.
for title in ["Edit", "Type", "View"] {
for path in submenu_paths(title) {
let translated = crate::i18n::tr("hu", &path);
if translated == path {
continue; // New, uncatalogued paths still fall back to English.
}
let visible = path.split('/').map(|part| crate::i18n::tr("hu", part)).collect::<Vec<_>>().join("/");
assert_eq!(translated, visible, "{path}");
}
}
}
fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) {
for it in items {
match it {
@@ -648,6 +667,7 @@ fn split_window_contextual_bars_have_independent_widget_ids() {
assert!(!bars[0].intersects(bars[1]), "each pane keeps its own bar: {bars:?}");
}
/// Checks the document-grid menu label, shortcut, checkmark and repeated toggle behavior.
#[test]
fn show_document_grid_toggles_from_the_grids_and_guides_menu() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -666,6 +686,8 @@ fn show_document_grid_toggles_from_the_grids_and_guides_menu() {
assert!(!app.ui.document_grid);
}
/// Checks language selection and rejection of unknown language codes, Preferences dispatch,
/// translated menu labels and preservation of document data when switching to Arabic.
#[test]
fn interface_language_switches_and_validates() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -683,6 +705,113 @@ fn interface_language_switches_and_validates() {
run_ui(&mut app, "app.language", &json!({})).unwrap().unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
assert_eq!(app.ui.language, "de");
app.ui.dialog = None;
run_ui(&mut app, "app.language", &json!({"lang": "zh"})).unwrap().unwrap();
assert_eq!(app.ui.language, "zh");
assert_eq!(crate::i18n::tr(&app.ui.language, "File"), "文件");
app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
run_ui(&mut app, "app.language", &json!({"lang": "ar"})).unwrap().unwrap();
assert_eq!(app.ui.language, "ar");
assert_eq!(checked(&app, "app.language", &json!({"lang": "ar"})), Some(true));
assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("ar", title), title, "{title}");
}
// Every menu title has a translation (Japanese never matches the English).
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("ja", title), title, "{title}");
}
run_ui(&mut app, "app.language", &json!({"lang": "pt-br"})).unwrap().unwrap();
assert_eq!(app.ui.language, "pt-br");
assert_eq!(checked(&app, "app.language", &json!({"lang": "pt-br"})), Some(true));
for (title, _) in menu_tree() {
// "Layout" is the natural Brazilian Portuguese word, so it stays identical
// to English; every other title must be translated.
if title != "Layout" {
assert_ne!(crate::i18n::tr("pt-br", title), title, "{title}");
}
}
run_ui(&mut app, "app.language", &json!({"lang": "it"})).unwrap().unwrap();
assert_eq!(app.ui.language, "it");
assert_eq!(checked(&app, "app.language", &json!({"lang": "it"})), Some(true));
for (title, _) in menu_tree() {
// "File" and "Layout" are the usual Italian menu names, so they stay identical
// to English; every other title must be translated.
if title != "File" && title != "Layout" {
assert_ne!(crate::i18n::tr("it", title), title, "{title}");
}
}
}
/// Checks Hungarian selection, menu captions and saved UI preferences without changing
/// document data or translating user-defined names.
#[test]
fn hungarian_language_is_selectable_persisted_and_keeps_document_data() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Árvíztűrő tükörfúrógép"})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
run_ui(&mut app, "app.language", &json!({"lang": "hu"})).unwrap().unwrap();
assert_eq!(checked(&app, "app.language", &json!({"lang": "hu"})), Some(true));
assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("hu", title), title, "{title}");
}
let bytes = serde_json::to_vec(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
assert_eq!(restored.language, "hu");
assert_eq!(crate::i18n::tr(&restored.language, "File"), "Fájl");
assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
assert!(crate::i18n::LANGUAGES.contains(&("hu", "Magyar")), "Preferences lists Hungarian by its own name");
}
/// Checks Ukrainian selection, menu captions and saved UI preferences without changing
/// document data or translating user-defined names.
#[test]
fn ukrainian_language_is_selectable_persisted_and_keeps_document_data() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова"})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
run_ui(&mut app, "app.language", &json!({"lang": "uk"})).unwrap().unwrap();
assert_eq!(checked(&app, "app.language", &json!({"lang": "uk"})), Some(true));
assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("uk", title), title, "{title}");
}
let bytes = serde_json::to_vec(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
assert_eq!(restored.language, "uk");
assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл");
assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
assert!(crate::i18n::LANGUAGES.contains(&("uk", "Українська")), "Preferences lists Ukrainian by its own name");
}
/// Checks Russian selection, menu captions and saved UI preferences without changing
/// document data or translating user-defined names.
#[test]
fn russian_language_is_selectable_persisted_and_keeps_document_data() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мои слова"})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
run_ui(&mut app, "app.language", &json!({"lang": "ru"})).unwrap().unwrap();
assert_eq!(checked(&app, "app.language", &json!({"lang": "ru"})), Some(true));
assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("ru", title), title, "{title}");
}
let bytes = serde_json::to_vec(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
assert_eq!(restored.language, "ru");
assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл");
assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
assert!(crate::i18n::LANGUAGES.contains(&("ru", "Русский")), "Preferences lists Russian by its own name");
}
#[test]
@@ -3152,3 +3281,70 @@ fn file_menu_ends_with_quit_but_the_macos_menu_bar_leaves_it_to_the_app_menu() {
let file_items = native.iter().find(|(t, _)| *t == "File").map(|(_, e)| e);
assert!(!matches!(file_items.and_then(|e| e.last()), Some(Item::Sep)), "no separator left at the end of File");
}
#[test]
fn zoom_commands_zoom_around_the_pointer_when_it_is_over_the_canvas() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
frame(&mut app, vec![]);
frame(&mut app, vec![]);
let rect = app.canvas_rect.unwrap();
let pointer = rect.center() + egui::vec2(200.0, 120.0);
let under = |app: &crate::DesignApp, at: egui::Pos2| crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_canvas(at);
let before = under(&app, pointer);
let zoom = app.view().unwrap().zoom;
// The command with a pointer position keeps the point under it fixed.
app.run("view.zoomIn", json!({"at": [pointer.x, pointer.y]})).unwrap();
assert!(app.view().unwrap().zoom > zoom);
let after = under(&app, pointer);
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
app.run("view.zoomOut", json!({"at": [pointer.x, pointer.y]})).unwrap();
let after = under(&app, pointer);
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
// A position outside the canvas (or none: the menu) zooms around the centre.
let centre = under(&app, rect.center());
app.run("view.zoomIn", json!({"at": [-50.0, -50.0]})).unwrap();
let kept = under(&app, rect.center());
assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3);
app.run("view.zoomIn", json!({})).unwrap();
let kept = under(&app, rect.center());
assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3);
// The keyboard shortcut uses the pointer.
app.run("view.zoom", json!({"zoom": 1.0})).unwrap();
let before = under(&app, pointer);
frame(&mut app, vec![egui::Event::PointerMoved(pointer)]);
let key = egui::Event::Key { key: egui::Key::Equals, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND };
frame(&mut app, vec![key]);
assert!(app.view().unwrap().zoom > 1.0);
let after = under(&app, pointer);
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
// The native menu bar's key equivalent (macOS) leaves the pointer in the window and uses it.
app.run("view.zoom", json!({"zoom": 1.0})).unwrap();
let before = under(&app, pointer);
native_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pointer)], Some("view.zoomIn"));
assert!(app.view().unwrap().zoom > 1.0);
let after = under(&app, pointer);
assert!((after.x - before.x).abs() < 1e-3 && (after.y - before.y).abs() < 1e-3);
// A click in the menu bar takes the pointer out of the window: the centre stays.
let centre = under(&app, rect.center());
native_frame(&mut app, &ctx, vec![egui::Event::PointerGone], Some("view.zoomOut"));
let kept = under(&app, rect.center());
assert!((kept.x - centre.x).abs() < 1e-3 && (kept.y - centre.y).abs() < 1e-3);
}
+3 -3
View File
@@ -71,7 +71,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
"app.language",
"Interface Language",
None,
"{lang?: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk|ru|cs|pl|ka} — menus and panel names (the macOS menu bar follows at once); without lang, opens Preferences, where it is the first setting",
"{lang?: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk|ru|cs|pl|ka|hu|sv} — menus and panel names (the macOS menu bar follows at once); without lang, opens Preferences, where it is the first setting",
),
(
"app.flattener",
@@ -178,8 +178,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.paragraphStylesPanel", "Paragraph Styles", Some("Cmd+F11"), "{} — the Paragraph Styles panel"),
("app.insertCurrentPageNumber", "Current Page Number", Some("Cmd+Alt+Shift+N"), "{} — the Current Page Number marker at the insertion point"),
("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"),
("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"),
("view.zoomIn", "Zoom In", Some("Cmd+="), "{at?: [x, y] window pixels, kept in place when over the canvas}"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{at?: [x, y] window pixels, kept in place when over the canvas}"),
("view.fitPage", "Fit Page in Window", Some("Cmd+0"), "{}"),
("view.fitSelection", "Fit Selection in Window", Some("Cmd+Alt+="), "{}"),
("view.fitSpread", "Fit Spread in Window", Some("Cmd+Alt+0"), "{}"),
+7
View File
@@ -11,6 +11,9 @@ pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type ClipboardFn = Box<dyn FnMut() -> Option<String>>;
/// Keeps the window's frame as the app needs it; called once per frame, before it is laid out.
pub type WindowFrameFn = Box<dyn FnMut()>;
/// Files delivered asynchronously with their original operation and document identity.
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<ImportedFile>>>;
@@ -32,4 +35,8 @@ pub struct Services {
/// The system clipboard's text, for Paste chosen from a menu (egui only delivers it with the
/// paste keys, which a native menu bar takes first).
pub clipboard_text: Option<ClipboardFn>,
/// Keeps the undecorated window borderless, so the content and the pointer stay aligned
/// (Windows custom title bar, #1295). `None` when the window is decorated or the platform
/// needs no fixing.
pub window_frame: Option<WindowFrameFn>,
}
+72 -36
View File
@@ -10,6 +10,8 @@ use crate::{DesignApp, icons};
const BTN: f32 = 24.0;
/// Draws the adaptive Tools panel with localized tool flyouts, fill/stroke controls and
/// screen-mode buttons; flyout rows accommodate translated labels and their shortcuts.
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let frame = egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.divider));
@@ -173,43 +175,77 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.ctx(),
|ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
for tl in g {
ui.horizontal(|ui| {
let (r, row) = ui.allocate_exact_size(vec2(210.0, 24.0), Sense::click());
if row.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
icons::paint(
ui.painter(),
egui::Rect::from_min_size(r.min + vec2(4.0, 3.0), vec2(18.0, 18.0)),
tl.icon,
t.icon,
);
if tl.id == current {
ui.painter().circle_filled(r.min + vec2(-2.0, 12.0), 2.0, t.accent);
}
ui.painter().text(
r.min + vec2(28.0, 12.0),
egui::Align2::LEFT_CENTER,
crate::i18n::tr(&app.ui.language, tl.label),
egui::FontId::proportional(12.5),
t.text,
);
if let Some(sc) = crate::menus::tool_shortcut_label(app, tl.id) {
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
sc,
egui::FontId::proportional(11.5),
t.text_dim,
);
}
row.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl.label));
if row.clicked() {
chosen = Some(tl.id);
}
});
let row_width = g.iter().fold(210.0_f32, |width, tl| {
let label = crate::i18n::tr(&app.ui.language, tl.label);
let label_width = ui
.painter()
.layout_no_wrap(label.to_owned(), egui::FontId::proportional(12.5), t.text)
.size()
.x;
let shortcut_width = ui
.painter()
.layout_no_wrap(
crate::menus::tool_shortcut_label(app, tl.id).unwrap_or_default(),
egui::FontId::proportional(11.5),
t.text_dim,
)
.size()
.x;
width.max(label_width + shortcut_width + 46.0)
});
let max_width = (ui.ctx().content_rect().width()
- egui::Frame::popup(ui.style()).total_margin().sum().x)
.max(0.0);
let mut scroll =
egui::ScrollArea::horizontal().id_salt(("tool-flyout-scroll", gi)).max_width(max_width);
if opened_now {
scroll = scroll.horizontal_scroll_offset(0.0);
}
scroll.show(ui, |ui| {
for tl in g {
ui.horizontal(|ui| {
let (r, row) = ui.allocate_exact_size(vec2(row_width, 24.0), Sense::click());
if row.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
icons::paint(
ui.painter(),
egui::Rect::from_min_size(r.min + vec2(4.0, 3.0), vec2(18.0, 18.0)),
tl.icon,
t.icon,
);
if tl.id == current {
ui.painter().circle_filled(r.min + vec2(-2.0, 12.0), 2.0, t.accent);
}
ui.painter().text(
r.min + vec2(28.0, 12.0),
egui::Align2::LEFT_CENTER,
crate::i18n::tr(&app.ui.language, tl.label),
egui::FontId::proportional(12.5),
t.text,
);
if let Some(sc) = crate::menus::tool_shortcut_label(app, tl.id) {
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
sc,
egui::FontId::proportional(11.5),
t.text_dim,
);
}
row.widget_info(|| {
egui::WidgetInfo::labeled(
egui::Role::Button,
true,
crate::i18n::tr(&app.ui.language, tl.label),
)
});
if row.clicked() {
chosen = Some(tl.id);
}
});
}
});
});
},
);
+195 -1
View File
@@ -19,10 +19,203 @@ fn column_width(cols: f32) -> f32 {
8.0 + super::BTN * cols + 4.0 * (cols - 1.0) + 8.0
}
/// Returns the bounds of the labeled test widget, failing if the label cannot be found.
fn rect(h: &egui_kittest::Harness<'static, test_window::Window>, label: &str) -> egui::Rect {
h.get_by_label(label).rect()
}
/// Collects painted text bounds and their clip rectangles, including nested shapes.
fn collect_text(shape: &egui::Shape, clip: egui::Rect, texts: &mut Vec<(String, egui::Rect, egui::Rect)>) {
match shape {
egui::Shape::Text(text) => texts.push((text.galley.text().to_owned(), text.visual_bounding_rect(), clip)),
egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect_text(shape, clip, texts)),
_ => {}
}
}
/// Checks translated tooltips and right-click flyout rows in both Tools-panel layouts,
/// including painted labels, unchanged shortcuts and unclipped, nonoverlapping text.
#[test]
fn hungarian_tool_buttons_translate_every_tool_and_keep_shortcuts() {
use egui::{Event, Modifiers, PointerButton};
for double in [false, true] {
let mut app = app(double);
app.ui.language = "hu".into();
let mut h = test_window::open(app, vec2(1400.0, 1000.0));
for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) {
h.state_mut().app.run("tool.select", serde_json::json!({"tool": tool.id})).unwrap();
h.run_steps(3);
let label = crate::i18n::tr("hu", tool.label);
assert_ne!(label, tool.label, "untranslated tool: {}", tool.id);
let shortcut = tool.shortcut.map(crate::menus::shortcut_text);
assert_eq!(crate::menus::tool_shortcut_label(&h.state().app, tool.id), shortcut, "default binding for {}", tool.id);
let tip = shortcut.map_or_else(|| label.to_owned(), |shortcut| format!("{label} ({shortcut})"));
assert!(rect(&h, &tip).is_finite(), "{tip}");
}
// A user-assigned chord also has to fit beside a translated flyout label.
let custom_shortcut = "Ctrl+Alt+Shift+F12";
h.state_mut()
.app
.run(
"window.setShortcut",
serde_json::json!({"id": "tool.select", "params": {"tool": "ellipseFrame"}, "shortcut": custom_shortcut, "steal": true}),
)
.unwrap();
for (gi, group) in designcraft_tools::TOOL_GROUPS.iter().enumerate() {
// Single tools have no flyout; the collector group is split into separate
// buttons in two-column mode and is covered by the tooltip checks above.
if group.len() < 2 || (double && designcraft_tools::SPLIT_IN_TWO_COLUMNS.contains(&group[0].id)) {
continue;
}
let first = group[0];
h.state_mut().app.run("tool.select", serde_json::json!({"tool": first.id})).unwrap();
h.run_steps(3);
let label = crate::i18n::tr("hu", first.label);
let tip = first.shortcut.map(crate::menus::shortcut_text).map_or_else(|| label.to_owned(), |shortcut| format!("{label} ({shortcut})"));
let at = rect(&h, &tip).center();
h.input_mut().events.push(Event::PointerMoved(at));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
}
h.run_steps(3);
assert_eq!(h.state().app.ui.flyout, Some(gi), "right-click opens {} (double={double})", first.id);
let mut texts = Vec::new();
for shape in &h.output().shapes {
collect_text(&shape.shape, shape.clip_rect, &mut texts);
}
for tool in *group {
let label = crate::i18n::tr("hu", tool.label);
// Flyout accessibility labels omit the shortcut; inspect the painted
// text inside this row so another button cannot satisfy the assertion.
let row = rect(&h, label);
assert!(row.is_finite() && row.is_positive(), "{label}");
let painted = |expected: &str| {
let (_, bounds, clip) = texts
.iter()
.find(|(text, bounds, _)| text == expected && row.contains(bounds.center()))
.unwrap_or_else(|| panic!("missing flyout text {expected:?} for {} (double={double})", tool.id));
assert!(row.contains_rect(*bounds) && clip.contains_rect(*bounds), "clipped flyout text {expected:?}: {bounds:?}");
*bounds
};
let label_bounds = painted(label);
let binding = if tool.id == "ellipseFrame" { Some(custom_shortcut) } else { tool.shortcut };
if let Some(shortcut) = binding.map(crate::menus::shortcut_text) {
let shortcut_bounds = painted(&shortcut);
assert!(label_bounds.right() < shortcut_bounds.left(), "label overlaps shortcut for {} (double={double})", tool.id);
}
}
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(h.state().app.ui.flyout.is_none());
}
assert_eq!(crate::i18n::tr("hu", "Direct Selection Tool"), "Közvetlen kijelölő eszköz");
}
}
/// Keeps long translated flyout rows reachable by scrolling in a narrow viewport,
/// resetting on reopening while preserving the open scroll position, shortcut and selection.
#[test]
fn hungarian_flyout_scrolls_horizontally_in_narrow_windows() {
use egui::{Event, Modifiers, PointerButton};
for double in [false, true] {
for width in [180.0, 240.0] {
let mut app = app(double);
app.ui.language = "hu".into();
let chord = "Ctrl+Alt+Shift+F12";
app.run(
"window.setShortcut",
serde_json::json!({"id": "tool.select", "params": {"tool": "ellipseFrame"}, "shortcut": chord, "steal": true}),
)
.unwrap();
app.select_tool("rectangleFrame");
let mut h = test_window::open(app, vec2(width, 1000.0));
let at = rect(&h, "Téglalapkeret eszköz (F)").center();
h.input_mut().events.push(Event::PointerMoved(at));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
}
h.run_steps(3);
let gi = h.state().app.ui.flyout.unwrap();
let area = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("flyout", gi)))).unwrap();
let viewport = h.ctx.content_rect();
assert!(viewport.expand(0.5).contains_rect(area), "flyout {area:?} exceeds viewport {viewport:?}");
let label = "Ellipsziskeret eszköz";
let shortcut = crate::menus::shortcut_text(chord);
let painted = |h: &egui_kittest::Harness<'static, test_window::Window>, text: &str| {
let row = rect(h, label);
let mut texts = Vec::new();
for shape in &h.output().shapes {
collect_text(&shape.shape, shape.clip_rect, &mut texts);
}
let (_, bounds, clip) = texts.into_iter().find(|(t, b, _)| t == text && row.contains(b.center())).unwrap();
(bounds, clip)
};
let (label_bounds, label_clip) = painted(&h, label);
assert!(label_clip.contains_rect(label_bounds), "label must be readable at the start");
let (before, clip) = painted(&h, &shortcut);
let oversized = rect(&h, label).width() > clip.width();
if width == 180.0 {
assert!(oversized, "the tighter case must exercise horizontal overflow on every platform");
}
review_shot(&mut h, &format!("flyout-{width}-double-{double}-left.png"));
if oversized {
assert!(!clip.contains_rect(before), "the shortcut starts beyond the narrow viewport");
wheel(&mut h, area.center(), vec2(-1000.0, 0.0));
let (after, clip) = painted(&h, &shortcut);
assert!(clip.contains_rect(after), "scrolling must make the complete shortcut readable");
assert!(after.left() < before.left(), "horizontal input must move the row");
h.run_steps(3);
let (settled, clip) = painted(&h, &shortcut);
assert!(clip.contains_rect(settled), "an open flyout must retain its horizontal scroll position");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(h.state().app.ui.flyout.is_none());
// Reopen through the toolbar input path, without changing the stored scroll state.
let at = rect(&h, "Téglalapkeret eszköz (F)").center();
h.input_mut().events.push(Event::PointerMoved(at));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton {
pos: at,
button: PointerButton::Secondary,
pressed,
modifiers: Modifiers::NONE,
});
}
h.run_steps(3);
assert_eq!(h.state().app.ui.flyout, Some(gi));
let (reopened, clip) = painted(&h, label);
assert!(clip.contains_rect(reopened), "a reopened flyout must show the complete translated label");
assert!((reopened.left() - label_bounds.left()).abs() < 0.5, "reopening must return to the initial left edge");
review_shot(&mut h, &format!("flyout-{width}-double-{double}-reopened.png"));
wheel(&mut h, area.center(), vec2(-1000.0, 0.0));
let (rescrolled, clip) = painted(&h, &shortcut);
assert!(clip.contains_rect(rescrolled), "a reopened flyout must still allow scrolling to the shortcut");
} else {
assert!(clip.contains_rect(before));
}
review_shot(&mut h, &format!("flyout-{width}-double-{double}-right.png"));
assert_eq!(crate::menus::tool_shortcut_label(&h.state().app, "ellipseFrame"), Some(shortcut));
let at = rect(&h, label).intersect(area).center();
h.input_mut().events.push(Event::PointerMoved(at));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
}
h.run_steps(3);
assert_eq!(h.state().app.session.tool_id(), "ellipseFrame");
assert!(h.state().app.ui.flyout.is_none());
}
}
}
/// Saves synthetic UI evidence only when the local review explicitly requests it.
fn review_shot(h: &mut egui_kittest::Harness<'static, test_window::Window>, name: &str) {
if let Some(dir) = std::env::var_os("DESIGNCRAFT_HU_REVIEW_SHOTS") {
h.render().unwrap().save(std::path::Path::new(&dir).join(name)).unwrap();
}
}
/// As InDesign's: a tool group's flyout closes on Esc and on a click outside it (the fill
/// swatch, a panel), not only on the canvas.
#[test]
@@ -197,7 +390,8 @@ fn the_tools_panel_lays_out_like_indesigns() {
for double in [false, true] {
let h = test_window::open(app(double), vec2(1400.0, 900.0));
let _ = rect(&h, "Gradient Swatch Tool (G)");
let _ = rect(&h, "Gradient Feather Tool (Shift+G)");
let feather = format!("Gradient Feather Tool ({})", crate::menus::shortcut_text("Shift+G"));
let _ = rect(&h, &feather);
let _ = rect(&h, "View Options");
let split = ["Content Placer Tool (B)", "Apply Color (,)", "Apply Gradient (.)", "Normal"];
for label in split {
+39
View File
@@ -0,0 +1,39 @@
//! The window-frame service keeps the custom title bar's undecorated window borderless (#1295).
//!
//! winit 0.30 gives an undecorated window a caption and border (`WS_CAPTION | WS_BORDER`), which
//! on some Windows systems offset the drawn content and the pointer. The app shell strips them
//! and re-strips them after every toolkit flag change (maximize, restore, …), driven from
//! `raw_input_hook`. These tests pin the shell side: the hook must run the service once per frame,
//! before the frame is laid out.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
#[test]
fn the_window_frame_service_runs_every_raw_input_hook() {
let calls = Arc::new(AtomicUsize::new(0));
let n = calls.clone();
let services = Services {
window_frame: Some(Box::new(move || {
n.fetch_add(1, Ordering::SeqCst);
})),
..Default::default()
};
let mut app = DesignApp::new(designcraft_engine::Session::new(), services);
let ctx = egui::Context::default();
for _ in 0..3 {
app.raw_input_hook(&ctx, &mut egui::RawInput::default());
}
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[test]
fn no_window_frame_service_is_fine() {
// Decorated windows and non-Windows platforms leave the service unset; the hook must simply
// do nothing.
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
let ctx = egui::Context::default();
app.raw_input_hook(&ctx, &mut egui::RawInput::default());
}
+1 -1
View File
@@ -156,7 +156,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
- **Estimate:** 25–40 h.
### 13. Hyphenation and spelling languages (T, L)
- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese, Norwegian, Greek, German (2006 reform), French and Italian; spelling for
- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese, Norwegian, Greek, German (2006 reform), French, Italian and Dutch; spelling for
every language but English. InDesign ships dictionaries for ~40 languages.
- **Estimate:** 30–60 h (permissively licensed patterns exist for most; spelling word lists need
licence review). **Doc:** [typography-parity.md](typography-parity.md)
+34 -7
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are reported, never a failure; catalog counts remain the 2026-10-10 measurement) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (Hungarian interface catalog, bundled-font coverage and flyout regression checks; Swedish interface) · **Target:** Adobe InDesign 2026 (21.6)
Per-language status of the DesignCraft interface and of document typesetting in each script.
Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md),
@@ -11,7 +11,7 @@ Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-ty
## How it was measured
Interface strings live in `crates/ui-egui/src/i18n/table.rs` (a shared table with German, French,
Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,cs,it,ja,ka,pt_br,uk}.rs`.
Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,cs,de,fr,hu,it,ja,ka,pl,pt_br,ru,sv,uk}.rs`.
Untranslated strings fall back to English. A script (2026-10-10) built the set of English
interface strings as the union of every catalog's keys, every engine command label and every
literal passed to `tr`/`t` in the UI crate: **1,724 strings**. A further ~145 capitalized literals
@@ -19,9 +19,11 @@ in the UI crate appear in no catalog (some are test data or proper names), so th
somewhat higher and every percentage below is a slight overestimate. Help: DesignCraft has no
help content in any language (Help ▸ opens the project's pages).
A missing translation never fails a test, a build or a release. `cargo xtask i18n-report` (also
a step of `cargo xtask ci`, so the list is in every gate log) prints how many interface strings
each language lacks, then the strings.
Missing translations normally fall back to English and are reported without blocking a build.
Targeted regression tests can require specific captions, such as the Data Merge arrangement
label, in every registered interface language. `cargo xtask i18n-report` (also a step of
`cargo xtask ci`, so the list is in every gate log) prints how many interface strings each
language lacks, then the strings.
InDesign 2026 ships 25 interface localizations (`.lproj` folders: cs, da, de, el, en, en_GB, es,
fi, fr, hu, it, ja, ko, nb, nl, pl, pt, ro, ru, sq, sv, tr, uk, zh_CN, zh_TW); Arabic and Hebrew
@@ -52,23 +54,46 @@ caption, table row/column movement and row-height distribution now use the exist
catalog. Print, import options and an active table were rendered in Japanese and English with
craft-fonts. The language totals above retain their dated measurement; this is a targeted fix.
Hungarian interface (2026-11-01): `hu.rs` contains **1,906 entries and four context-specific
captions**, including all 37 tool names. The initial catalog was prepared with AI assistance
and personally reviewed in the app by **Veress Róbert**, a native Hungarian speaker with over
30 years of DTP experience. Further terminology and in-context feedback is welcome. This is a
partial catalog, not a new coverage measurement against current main: uncatalogued strings
fall back to English. Preferences lists `Magyar`; interface selection does not enable the
separate spelling and hyphenation work in #1484.
The interface uses bundled **Source Sans 3 Regular** and **Source Sans 3 Semibold**; monospace
labels use **JetBrains Mono Regular** (`assets/fonts/`, registered by `theme::font_definitions`).
`hungarian_catalog_glyphs_exist_in_the_bundled_ui_fonts` checks the actual configured font
bytes, every Latin letter used by the catalog and all Hungarian accented vowels in both cases,
including **ŐőŰű**, with system and optional craft-fonts fallbacks disabled. No new font asset
or font dependency is needed.
Hungarian regressions cover placeholder/context integrity, formal address and count/column
terminology, language persistence, matching menu paths, translated tool labels and real
right-click flyouts in both Tools-panel layouts. Narrow-window coverage exercises horizontal
scrolling, complete label/shortcut accessibility and selecting a scrolled row at 180 and
240 points; the existing wide-window checks keep their assertions.
## Other shipped languages
| Language | Code | UI strings translated | Status |
|---|---|---|---|
| Hungarian | hu | 1,906 entries + 4 context captions (2026-11-01); initial catalog native-reviewed | partial |
| Czech | cs | 1,722 (99%), plural categories handled | partial |
| Italian | it | 1,464 (85%) | partial |
| Ukrainian | uk | 1,432 (83%), plural categories handled | partial |
| Polish | pl | 1,717 (99%), count captions category-first; AI-drafted, awaiting native review | partial |
| Georgian | ka | 1,724 (~100%: every catalog key plus the command labels the others lack), count captions as `noun: n` | partial (reviewed by a native speaker) |
| Swedish | sv | 2,857 of 2,891 (99%, 2026-11-01 missing-translations report): menus, panels, dialogs and messages; singular for a count of one; AI-drafted with Claude Code from the English source, spot-checked by a native speaker (not every string) | partial |
DesignCraft ships **5** interface languages beyond the twelve; InDesign ships 17 beyond its
DesignCraft ships **6** interface languages beyond the twelve; InDesign ships 17 beyond its
eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, tr, uk, zh_TW).
## Document languages
InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries).
DesignCraft hyphenates **English, French, German (2006 reform), Greek, Italian, Norwegian, Portuguese and Spanish** and spell-checks **English** only. Text language
DesignCraft hyphenates **English, Dutch, French, German (2006 reform), Greek, Italian, Norwegian, Portuguese and Spanish** and spell-checks **English** only. Text language
can be set per paragraph and character (Properties, Character panel) and drives quotes and
locale shaping. See [typography-parity.md](typography-parity.md).
German hyphenation accepts `German: 2006 Reform`, `German: Swiss 2006 Reform` and `de` locale
@@ -92,7 +117,9 @@ German variants have no automatic hyphenation; their German shaping and quotes a
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Initial Hungarian interface: 1,906 entries, four context captions, native review, bundled-font glyph coverage and wide/narrow flyout regressions |
| 2026-10-11 | minor | Localized Japanese print, import and table controls; checked Japanese and English renders |
| 2026-10-15 | minor | Swedish (`sv`) interface added |
| 2026-10-14 | minor | Georgian (`ka`) interface added |
| 2026-10-10 | major | Created: catalogs measured (1,724 strings), the twelve key languages plus Italian and Ukrainian |
| 2026-10-11 | minor | Czech (cs) added: 1,722 strings, plural categories handled |
+3 -3
View File
@@ -59,7 +59,7 @@ they receive, setting type exactly, and basic editing that never fails.
| Dimension | Weight | Ready | Remaining (Opus 5.5 h) | Evidence | Doc |
|---|---|---|---|---|---|
| Features (depth of each area) | 25% | 55% | 250–400 | 275 catalogue rows exist; many lack options (e.g. Books is 258 lines of engine code, Liquid Layout 329); see the area table | this file |
| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English, Spanish, Portuguese, Norwegian, Greek and German 2006 only | [typography-parity.md](typography-parity.md) |
| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English, Spanish, Portuguese, Norwegian, Greek, German 2006, French, Italian and Dutch only | [typography-parity.md](typography-parity.md) |
| File formats | 20% | 40% | 150–250 (+10–30 for an INDD corpus) | INDD and INDT open through indd-utils, measured on synthetic bytes only (#21); IDML both ways and verified in InDesign 2026, but real-file bugs open (#193, #338, #339, #340); PDF export strong; PDF/X-4 only | [file-format-parity.md](file-format-parity.md) |
| UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165) failing | [ui-parity.md](ui-parity.md) |
| Stability | 10% | 45% | 60–100 | Never-crash rules and last-resort guards; startup failures on some GPUs and machines (#334, #273, #220, #192, #167, #166); no CI runs the tests on pull requests | [gaps.md](gaps.md) |
@@ -293,7 +293,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Column guides: with the Selection tool, drag an interior column guide to move its gutter (both guides together, keeping its width, stopped by the neighbouring guides and the margins), snapping to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels; one undo step. Refused while Lock Column Guides is on (the default). A document page takes the result as its own columns; on a parent page it reaches the pages that follow it. Objects stay where they are (there is no persistent Layout Adjustment setting yet). `layout.moveColumnGuide`.
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
- Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked.
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself.
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` (with each face's OS/2 `fsType` embedding bits) / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself.
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample).
- Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement through craft-libs/craft-atomic, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original, and already-open readers retain the old bytes. On Unix a new file gets the permissions ordinary file creation gives it (the umask applies) and a replaced file keeps its permissions. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee.
@@ -362,7 +362,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`, Window ▸ Output ▸ Separations Preview, `ink.separations`): the plates the print PDF puts on film, spot plates included. Each visible spread is exported as Export PDF writes it and separated with craft-prepress, so the Ink Manager's aliases and spots to process, overprint and the flattener show as printed. All inks as printed, one plate as film, or an ink limit in red over gray; the panel lists the plates with each ink's percentage under the pointer.
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause) and Norwegian with hyph-utf8's `hyph-no.tex` (permissive), all through the `hypher` crate; Greek uses `hyph-el-monoton.tex` (MIT). German uses hyph-utf8's `hyph-de-1996.tex` 2024-02-28 (MIT), which contains the reformed 2006 patterns despite its filename. Only the explicit 2006 reform names and plain or 2006 German locale codes use this set; `German: Traditional`, `de_DE_1901` and unknown German variants have no automatic hyphenation. No separate old-spelling or Swiss set is bundled. Hyphenation for other languages isn't bundled yet.
- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause) and Norwegian with hyph-utf8's `hyph-no.tex` (permissive) and Dutch with Piet Tutelaers's `hyph-nl.tex` 1.1 (MIT), all through the `hypher` crate; Greek uses `hyph-el-monoton.tex` (MIT). German uses hyph-utf8's `hyph-de-1996.tex` 2024-02-28 (MIT), which contains the reformed 2006 patterns despite its filename. Only the explicit 2006 reform names and plain or 2006 German locale codes use this set; `German: Traditional`, `de_DE_1901` and unknown German variants have no automatic hyphenation. No separate old-spelling or Swiss set is bundled. Hyphenation for other languages isn't bundled yet.
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, chapter numbers resolved in book order (Automatic, Start Chapter Numbering at, Same as Previous Document; `layout.chapterNumbering`, with a style for Chapter Number variables and cross-references), style sync from a style source, one merged PDF. Section prefixes follow InDesign's rule (up to 8 characters, no + or comma).
- Numbering & Section Options window as in InDesign (Start Section, automatic or explicit page numbering up to 99999, prefix, style, marker, Include Prefix; the document's chapter numbering and its book); OK applies both in one undo step (`layout.numberingOptions`).
- Book numbering/style updates validate and serialize all targets before changing the first chapter. They write only closed native chapters, checking canonical paths for open aliases; IDML remains a read-only style source and PDF input. Snapshots are staged on disk with at most 4,096 chapter records in memory. Each replacement is atomic; a later write failure explicitly lists completed chapters rather than claiming whole-book rollback. Import/serialization memory limits and advanced book TOC/index work remain separate.
+1 -1
View File
@@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated.
| Single-line Composer | greedy | greedy with the same ranges | ✓ | |
| World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested |
| Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) |
| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no), Greek (hyph-el-monoton), German 2006 (hyph-de-1996), French (hyph-fr) and Italian (hyph-it); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no), Greek (hyph-el-monoton), German 2006 (hyph-de-1996), French (hyph-fr), Italian (hyph-it) and Dutch (Tutelaers); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
| Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | |
| Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently |
| Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | |
+2
View File
@@ -16,12 +16,14 @@
<string>de</string>
<string>es</string>
<string>fr</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ka</string>
<string>pl</string>
<string>pt-BR</string>
<string>ru</string>
<string>sv</string>
<string>uk</string>
<string>zh-Hans</string>
</array>