mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 22:26:49 -06:00
Merge remote-tracking branch 'origin/main' into land-2109
This commit is contained in:
@@ -63,6 +63,7 @@ jobs:
|
||||
needs: git-sources
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
Generated
+22
-21
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -389,6 +389,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
Newest first.
|
||||
|
||||
- **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.
|
||||
@@ -405,6 +406,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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+10
-1
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ mod ar;
|
||||
mod cs;
|
||||
mod de;
|
||||
mod fr;
|
||||
mod hu;
|
||||
mod it;
|
||||
mod ja;
|
||||
mod ka;
|
||||
@@ -36,6 +37,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[
|
||||
("cs", "Čeština"),
|
||||
("pl", "Polski"),
|
||||
("ka", "ქართული"),
|
||||
("hu", "Magyar"),
|
||||
];
|
||||
|
||||
/// The interface languages in the order the Preferences picker lists them: English first (the
|
||||
@@ -77,6 +79,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 +94,10 @@ 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);
|
||||
}
|
||||
if lang == "ar" {
|
||||
return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
|
||||
}
|
||||
@@ -145,6 +153,7 @@ 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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ 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),
|
||||
@@ -17,8 +18,15 @@ const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[
|
||||
];
|
||||
|
||||
/// 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),
|
||||
("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 +39,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 +70,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 +247,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 +281,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 +466,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]",
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -575,7 +575,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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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} — 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"), "{}"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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) · **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,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,45 @@ 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) |
|
||||
|
||||
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,6 +116,7 @@ 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-14 | minor | Georgian (`ka`) interface added |
|
||||
| 2026-10-10 | major | Created: catalogs measured (1,724 strings), the twelve key languages plus Italian and Ukrainian |
|
||||
|
||||
@@ -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) |
|
||||
@@ -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.
|
||||
|
||||
@@ -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 | ✓ | ✓ | ✓ | |
|
||||
|
||||
@@ -20,10 +20,12 @@ APP_ID=ai.storyteller.designcraft
|
||||
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
TEST=1
|
||||
OCI=0
|
||||
TARBALL=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-test) TEST=0; shift ;;
|
||||
--oci) OCI=1; shift ;;
|
||||
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
*) TARBALL="$1"; shift ;;
|
||||
@@ -61,6 +63,12 @@ OUT="$DIST/$BASENAME.flatpak"
|
||||
# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
|
||||
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
|
||||
echo "wrote $OUT"
|
||||
if [[ "$OCI" = 1 ]]; then
|
||||
OUT_OCI="$DIST/$BASENAME-oci"
|
||||
rm -rf "$OUT_OCI"
|
||||
flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable
|
||||
echo "wrote OCI layout to $OUT_OCI"
|
||||
fi
|
||||
|
||||
if [[ "$TEST" = 1 ]]; then
|
||||
flatpak install --user -y --noninteractive --reinstall "$OUT"
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build designcraft as an OCI container layout and push it to GitHub Container Registry (GHCR)
|
||||
# for consumption by Flatpak OCI clients (flatpak-oci-specs).
|
||||
#
|
||||
# Usage:
|
||||
# packaging/linux/flatpak-oci.sh [--push] [TARBALL]
|
||||
#
|
||||
# Environment variables:
|
||||
# REGISTRY Target registry image (default: ghcr.io/storytold/designcraft)
|
||||
# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH)
|
||||
# GITHUB_TOKEN Token for GHCR authentication when --push is used
|
||||
set -euo pipefail
|
||||
# shellcheck source=../env.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
||||
HERE="$ROOT/packaging/linux"
|
||||
|
||||
PUSH=0
|
||||
TARBALL=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--push) PUSH=1; shift ;;
|
||||
-h | --help) sed -n '2,12p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
*) TARBALL="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ;;
|
||||
aarch64 | arm64) ARCH=aarch64 ;;
|
||||
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
BASENAME="designcraft-$VERSION-linux-$ARCH"
|
||||
OUT_OCI="$DIST/$BASENAME-oci"
|
||||
|
||||
echo "==> Building Flatpak OCI layout for $BASENAME"
|
||||
"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}"
|
||||
|
||||
[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; }
|
||||
|
||||
REGISTRY="${REGISTRY:-ghcr.io/storytold/designcraft}"
|
||||
TAG="${TAG:-$VERSION-$ARCH}"
|
||||
|
||||
echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)"
|
||||
|
||||
if [[ "$PUSH" = 1 ]]; then
|
||||
command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; }
|
||||
|
||||
AUTH_ARGS=()
|
||||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||||
AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN")
|
||||
fi
|
||||
|
||||
echo "==> Pushing to $REGISTRY:$TAG"
|
||||
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG"
|
||||
|
||||
# Also update architecture-specific stable tag
|
||||
echo "==> Pushing to $REGISTRY:stable-$ARCH"
|
||||
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH"
|
||||
|
||||
echo "==> Push complete"
|
||||
fi
|
||||
@@ -16,6 +16,7 @@
|
||||
<string>de</string>
|
||||
<string>es</string>
|
||||
<string>fr</string>
|
||||
<string>hu</string>
|
||||
<string>it</string>
|
||||
<string>ja</string>
|
||||
<string>ka</string>
|
||||
|
||||
Reference in New Issue
Block a user