From d6a67bdcd1e804fe7fdbf6568e2b6448d20e5355 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 11:25:00 -0400 Subject: [PATCH 01/17] CJK: plan for the East Asian feature set Phased plan for professional CJK typesetting (the East Asian feature set): sources (JLREQ, CLREQ, KLREQ, JIS X 4051, OpenType, IDML), current state and known bugs, architecture (character classes, mojikumi, kinsoku and the Japanese composer, vertical metrics, annotations, grids, composite fonts), IDML attributes, phases with estimates and open decisions. It builds on the CJK composition of docs/cjk-typography.md (composite fonts, kinsoku sets, aki, tsume, jidori, hanging punctuation, em alignment and leading models, kenten characters, tate-chu-yoko offsets) and plans what remains. --- docs/cjk.md | 273 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 docs/cjk.md diff --git a/docs/cjk.md b/docs/cjk.md new file mode 100644 index 0000000..534b36c --- /dev/null +++ b/docs/cjk.md @@ -0,0 +1,273 @@ +# CJK typesetting plan + +Goal: the Chinese, Japanese and Korean typesetting that InDesign offers with its East Asian +feature set (the "Japanese/CJK" feature set, `Feature Set Locale Setting` 257), so that a +DesignCraft document can be set to the standard of a Japanese, Chinese or Korean publisher, +round-trip through IDML, and export correct PDF and EPUB. + +It builds on the CJK import and composition described in [cjk-typography.md](cjk-typography.md) +(composite fonts, kinsoku sets, aki, tsume, jidori, hanging punctuation, em alignment and leading +models, kenten characters, tate-chu-yoko offsets, preserved mojikumi tables) and plans what +remains. + +## Sources (clean-room) + +Behaviour comes from public specifications, not from InDesign's implementation: + +- W3C *Requirements for Japanese Text Layout* (JLREQ): character classes, line composition, + spacing (aki), line breaking (kinsoku), ruby, kenten, warichu, tate-chu-yoko, grids. +- W3C *Requirements for Chinese Text Layout* (CLREQ) and *Korean* (KLREQ): punctuation placement + for Simplified and Traditional Chinese, Korean word-based line breaking. +- JIS X 4051 (published standard; JLREQ follows it). +- OpenType: `vmtx`/`vhea`/`VORG`, `BASE`, the `vert`/`vrt2`/`vkrn`/`vpal`/`palt`/`halt`/`vhal`, + `locl`, `ruby`, width (`fwid`/`hwid`/`pwid`/`qwid`/`twid`) and form (`jp78`/`jp83`/`jp90`/`jp04`, + `trad`, `expt`, `nlck`, `hojo`) features. +- The published IDML specification for attribute names and values. + +InDesign itself is only observed black-box (feature names, which options exist); no wording, +presets or tables are copied. Our default mojikumi and kinsoku tables are built from JLREQ. + +## Where we are (2026-10) + +| Area | State | +|---|---| +| Vertical frames, upright CJK, rotated Latin | done (per frame; glyphs turned by transforms) | +| `vert`/`vrt2` in vertical frames | done (applied to every run, Latin included); Horizontal Kana (`hkna`) becomes `vkna` | +| Kinsoku | named (hard, soft, Chinese, Korean) and custom kinsoku sets, bunri-kinshi, rensuuji, hanging punctuation (regular, force); without a set, fixed no-start / no-end lists and a break anywhere between CJK characters; kinsoku type (push in / push out priorities) kept but not applied | +| Mojikumi | tables and paragraph references kept through IDML; not applied (Preflight says so) | +| Aki, tsume, jidori | done ([cjk-typography.md](cjk-typography.md)) | +| Character alignment, leading model | em box top / centre / bottom, ICF from ascender and descender, roman baseline; aki above / below, centre (centre down as centre) | +| Tate-chu-yoko | manual, with offsets; no auto, 3+ digits overflow the em | +| Ruby | group ruby only, fixed 50 % size, no options, doesn't affect leading | +| Kenten | the IDML kinds and a custom character, drawn as characters; no position, size, alignment, colour or font | +| Composite fonts | done: model, `style.compositeFont.*`, shaping, IDML, font list, replace and Preflight; no dialog | +| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; language-blind fallback; no per-document fonts | +| Everything else below | missing | + +Known bugs to fix first: + +- Korean breaks between every syllable (it should break at spaces by default). +- Only Arabic, Persian, Urdu, Hebrew and English language names reach the shaper as languages + it knows, so `locl` never applies for the others (Japanese, Chinese, Korean, Turkish…); Chinese + and Korean text get Japanese glyph forms and the Japanese fallback font. +- Vertical advances are the horizontal ones and upright glyphs turn about a guessed centre + (`adv × 0.38`), so proportional and non-square glyphs sit wrong in vertical text. +- A justified CJK line has no stretch except Single Word Justification. + +## Architecture + +### Character classes + +A `cjk::class_of(char, &Context) -> Class` in `designcraft-compose` with the JLREQ classes +(opening brackets, closing brackets, hyphens, dividing punctuation, middle dots, full stops, +commas, inseparable characters, iteration marks, prolonged sound mark, small kana, prefixed and +postfixed abbreviations, full-width ideographic space, hiragana, katakana, math symbols and +operators, ideographs, Western characters, numerals, ruby bases, warichu brackets, …). Language +and the paragraph's punctuation style decide the ambiguous ones (e.g. Chinese full-width +punctuation centred vs corner-placed). + +### Spacing: mojikumi + +A *mojikumi set* is a table indexed by (class before, class after) giving the space between them +as a min/optimum/max triple in em fractions, plus line-start and line-end rules. Sets are +document resources (`Styles::mojikumi_tables`, read from and written to IDML with their override +rows), referenced by name from the paragraph's `mojikumi`; what remains is applying them. We +ship a few built-in sets derived from JLREQ (no line-start indent, one-em indent, half-width +punctuation, full-width punctuation…), all original data, for the IDML built-in names. + +Shaping keeps the glyphs' own advances; mojikumi is applied after shaping as *aki* glue between +glyphs: a full-width bracket's empty half becomes negative aki (compression) and the composer +can widen or narrow each gap within its range. This keeps `palt`-style proportional fonts +working (tsume removes the side bearings before mojikumi runs). + +### Line breaking: kinsoku and the Japanese composer + +- Kinsoku sets (`doc::cjk::Kinsoku`: characters not allowed at line start, at line end, that may + hang, and inseparable pairs), the paragraph's set, hanging (none / regular / force), + bunri-kinshi and rensuuji are in place: they constrain breaks in both breakers. Remaining: the + built-in hard and soft sets from JLREQ's classes (today's named sets are common punctuation + lists), and the kinsoku type (push in first / push out first / push out only / prioritise the + adjustment amount), which needs the Japanese composer's push-in and push-out candidates. +- A `Composer::Japanese` (paragraph) and `Composer::JapaneseSingleLine` beside the Western + composers. In `breaker::items` the Japanese composer emits: + - boxes per glyph, + - mojikumi glue between glyphs with priority tiers (compress punctuation first, then + expand inter-ideograph space, then Western/CJK spacing, then letter-spacing), + - kinsoku penalties (infinite inside prohibited pairs; push-in candidates scored by + compression needed), + - hanging punctuation as zero-width boxes allowed past the measure. + Knuth–Plass already takes tiered stretch (`TIER_COST`), so this plugs into `kp_pass`. +- Justification in `layout_line` distributes by the same tiers; the last line follows the + paragraph's last-line alignment; CJK lines justify by default (JLREQ). +- Korean: the Japanese composer with word-based breaking (spaces) by default; per-paragraph + option for character-based breaking. + +### Vertical text, properly + +- Read `vmtx`, `vhea`, `VORG` and the ideographic em box (`BASE` `ideo`/`idtp`, else OS/2 typo + metrics). `FontFace` gets `v_advance(gid)`, `v_origin(gid)` and `em_box()`. +- Shape upright runs with direction `TopToBottom` so `vert`, `vkrn` and `vpal` apply and the + shaper reports vertical advances; rotated (Latin) runs stay horizontal and turn as a whole. + `vrt2` only for runs that are rotated, never with an extra turn. +- Glyph placement in vertical lines uses the vertical origin, not a guessed centre. +- Story direction per story (IDML `StoryOrientation`) rather than per frame; frames of one story + agree. +- Arrow keys and carets follow the vertical line direction. + +### Annotations and inline structures + +All set after line layout like today's ruby, but able to affect line height: + +- **Ruby**: per-character (mono), group and jukugo ruby; alignment (1-2-1, centre, left/right, + equal space, JIS rule), position (above/right, below/left), size, font, colour, x/y scale, + offsets, overhang onto neighbours (none / ½ / 1 ruby character / ½ base / unlimited), automatic + scaling, spacing to the base, "ruby adjusts leading". +- **Kenten**: position, size, alignment, colour, font (the kinds and a custom character are + there). +- **Warichu**: an inline run set in 2+ smaller lines inside one line height, with size, line + spacing, alignment and breaking rules. +- **Tate-chu-yoko**: scale-to-fit-em, automatic TCY for runs of N digits (with or without + Latin), rensuji (offsets are there). +- **Shatai** (oblique in the CJK sense: angle and magnification, keeping the em box), + **character rotation**. +- **Character alignment and leading model**: ICF top and bottom from the font's ideographic + character face (`BASE` `icft`/`icfb`) rather than its ascender and descender, and centre down + as its own model. + +Jidori, tsume, aki before and after, em-box alignment and the other leading models are in place +([cjk-typography.md](cjk-typography.md)). + +### Grids + +- **Layout grid** (per page / parent): character size, characters per line, lines per column, + columns and gutter, origin; drawn like the baseline grid; snap target. +- **Frame grids**: text frames that show a character grid (horizontal or vertical), with a + character count, named grids as document resources (font, size, scale, aki between characters + and lines, alignment, view options), grid alignment of paragraphs (em-box centre, ICF top/bottom, + roman baseline, gyoudori: lines spanning N grid lines), grid tracking. +- Frame Grid tools (horizontal and vertical) and Object › Frame Type (text frame ↔ frame grid). + +### Fonts + +- **Composite fonts** (a named font made of a base font plus fonts for character sets, with size, + baseline, x/y scale and centre adjustments) are resolved before shaping, read from and written + to IDML and listed, replaced and preflighted by their member fonts; PDF embeds the member fonts. + Remaining: the Composite Fonts dialog, and the predefined character classes (kanji, kana, + full-width symbols, punctuation, Latin, numerals) beside custom sets. +- **Language-aware fallback**: Japanese, Simplified Chinese, Traditional Chinese and Korean each + have their own fallback chain of system fonts (Japanese: the craft-fonts Japanese faces first); + `locl` follows the character language (IDML language names). +- **No CJK fonts in the repo.** CJK fonts are large (15–25 MB each even subset) and documents + should name the fonts they use. The Japanese UI and document faces come from the optional + craft-fonts build input when DesignCraft is built with it (release builds are). +- **Document fonts**: fonts in a `Document Fonts` folder beside an opened `.designcraft` or + `.idml` file are available to that document only, ahead of system fonts, and unloaded when it + closes. File › Package copies the document's fonts into the package's `Document Fonts` folder + (font licences permitting: OS/2 `fsType` restricted-licence fonts are listed, not copied), so a + packaged CJK document opens with its fonts on another machine. On the web, the fonts of a + dropped or opened package folder load the same way. + +### Editing and output + +- Find/Change: kana-sensitive and width-sensitive matching; full/half-width and + hiragana/katakana conversion (Type › Change Case). +- Numbering styles: kanji, full-width, circled, parenthesised. +- Typographer's quotes per language (Simplified/Traditional Chinese, Korean). +- Character count (Info panel) by CJK conventions. +- EPUB/HTML: `writing-mode: vertical-rl`, ``, `text-combine-upright`, `text-emphasis`. +- PDF: correct `ActualText` for ruby/warichu; vertical glyph placement as above. +- IDML: every attribute below, import and export, round-trip tested. + +## Feature set (UI) + +Preferences › Type gets a *Feature Set* choice (Western, CJK). CJK shows the East Asian +controls in the Character and Paragraph panels, styles dialogs and menus (Kinsoku, Mojikumi, +Composite Fonts, Named Grids, Layout Grid), makes the Japanese composer the default for new +documents and offers the frame grid tools. Western hides them; documents keep their settings +either way (opening a CJK document in Western mode loses nothing). Every feature stays reachable +as a command regardless of the feature set (agents, MCP). + +## IDML attributes to support + +Already read and written: `KentenKind`, `KentenCustomCharacter`, `RubyFlag`, `RubyString`, +`Tatechuyoko`, `TatechuyokoXOffset`, `TatechuyokoYOffset`, `Jidori`, `Tsume`, `LeadingAki`, +`TrailingAki`, `CharacterAlignment`, `LeadingModel`, `GlyphForm`, `OTFProportionalMetrics`, +`OTFHVKana`, `OTFRomanItalics`; paragraph `KinsokuSet`, `KinsokuType`, `KinsokuHangType`, +`BunriKinshi`, `Rensuuji`, `MojikumiTable`, `TreatIdeographicSpaceAsSpace`; resources +`KinsokuTable`, `MojikumiTable`, `CompositeFont`, `StoryPreference` direction. + +Character: `KentenFont`, `KentenFontStyle`, `KentenSize`, `KentenPosition`, `KentenAlignment`, +`KentenCharacterSet`, `KentenXScale`, `KentenYScale`, `KentenFillColor`/`Tint`, `RubyType`, +`RubyAlignment`, `RubyPosition`, `RubyFont`, `RubyFontStyle`, `RubyFontSize`, `RubyXScale`, +`RubyYScale`, `RubyXOffset`, `RubyYOffset`, `RubyOverhang`, `RubyParentSpacing`, +`RubyParentOverhangAmount`, `RubyAutoAlign`, `RubyAutoScaling`, `RubyAutoTcyDigits`, `Rensuji`, +`CharacterRotation`, `ShataiMagnification`, `ShataiDegreeAngle`, `ShataiAdjustRotation`, +`ShataiAdjustTsume`, `Warichu`, `WarichuLines`, `WarichuSize`, `WarichuLineSpacing`, +`WarichuAlignment`, `WarichuCharsBeforeBreak`, `WarichuCharsAfterBreak`. + +Paragraph: `Composer` (the Japanese composers), `GridAlignment` (all values), +`GridAlignFirstLineOnly`, `GridGyoudori`, `ParagraphGyoudori`, `CjkGridTracking`, `AutoTcy`, +`AutoTcyIncludeRoman`, `RotateSingleByteCharacters`, `ScaleAffectsLineHeight`. + +Resources: `MojikumiTableSet`, `NamedGrid`, layout grid and frame grid preferences, +`StoryPreference` orientation. + +Exact names and enumerations are checked against the IDML specification as each phase lands. + +## Phases + +Each phase lands as reviewable commits with tests (compose tests for layout, engine tests for +commands, IDML round trips, a rendered sample page) and `cargo xtask ci`. + +| # | Phase | Contents | Estimate (agent hours) | +|---|---|---|---| +| 1 | Foundations | language passed to the shaper (`locl`); Simplified/Traditional Chinese and Korean languages; language-aware CJK fallback; document fonts folder; Korean word breaking; `vmtx`/`VORG`/em box and vertical shaping; story-level direction; vertical caret keys | 12–17 | +| 2 | Japanese composer | character classes; mojikumi applied (built-in sets and the document's tables) as aki glue; JLREQ kinsoku sets and kinsoku types (push in / push out); Japanese paragraph and single-line composers; CJK justification and last line; Chinese punctuation styles; IDML | 20–30 | +| 3 | Inline CJK | full ruby, kenten options, warichu, auto TCY and scale-to-fit, rensuji, shatai, character rotation, ICF alignment from the font; IDML | 20–30 | +| 4 | Grids | layout grid, frame grids and tools, named grids, grid alignment and gyoudori, grid tracking, character count; IDML | 20–30 | +| 5 | Fonts | Composite Fonts dialog and predefined character classes; CJK OpenType features in the OpenType menu | 5–8 | +| 6 | Editing and output | kana/width-sensitive Find/Change, width and kana conversion, CJK numbering, quotes, EPUB/HTML vertical text and ruby | 10–15 | +| 7 | Feature set UI | Preferences › Feature Set, panels and dialogs for every attribute, Japanese defaults for new documents, translations | 15–20 | + +Total: roughly 100–150 agent hours. + +### Phase 1 in detail + +1. `CharProps.language` reaches `designcraft_fonts::shape` (harfrust buffer language) and the + word cache key; tests: a font with `locl` for JAN vs ZHS draws different glyphs for U+76F4. +2. Language list: "Chinese: Simplified", "Chinese: Traditional", "Korean", "Japanese" (IDML + names), with quotes and spelling/hyphenation off for them. +3. Fallback chains by language in `FontDb::fallback_for` (system fonts per platform; the + craft-fonts Japanese faces first for Japanese); tests with stand-in fonts and a fake catalog. +4. Document fonts: opening a document loads the fonts in its `Document Fonts` folder for that + document; Package copies used fonts there (licence permitting) and reports the ones it + couldn't copy; tests with a synthetic package folder. +5. Korean: `cjk_break_between` doesn't break between Hangul syllables unless the paragraph asks + for character breaking; test with a Korean sentence. +6. `FontFace::v_advance/v_origin/em_box` from `vmtx`/`vhea`/`VORG`/`BASE`; upright glyphs in + vertical lines use them; tests against synthetic fonts with and without `vmtx` (without, the + em box), and Shippori Mincho's tables when built with craft-fonts. +7. Upright runs shaped top-to-bottom (`vert`, `vkrn`, `vpal` apply); `vrt2` never on turned + runs; test that a full-width comma takes its vertical form and position. +8. Story direction at story level (frames of a story agree; IDML maps both ways); arrow keys move + along the vertical line. + +## Decisions + +Each follows what an InDesign user expects. + +1. **Feature set gating.** East Asian controls are hidden in the Western feature set and shown in + the CJK feature set, as in InDesign. Every feature stays reachable as a command either way. +2. **Korean line breaking** is word-based by default (KLREQ), with a per-paragraph option to break + between syllables. +3. **PDF vertical text** keeps transformed horizontal glyphs (correct-looking, copyable via + ActualText) rather than vertical CID fonts (`Identity-V`). +4. **No CJK fonts in the repo**; documents carry theirs in `Document Fonts`, scoped to the + document as in InDesign. DesignCraft embeds Japanese UI and document faces only when built with + the optional craft-fonts input (release builds are). +5. **Package copies every font that draws text**, fallback fonts included, so a package + reproduces what is on screen. +6. **Korean fallback is serif first** (Myungjo), as Chinese and Japanese fall back to Song and + Mincho: the serif faces CJK editions of layout software default to. +7. **Language names on IDML import** accept the common spellings of Simplified and Traditional + Chinese, since the exact names InDesign writes are unconfirmed without a sample file. From 2a516761ef17f26ff3f3a5bf4d87143a43f3ba0e Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 09:03:06 -0400 Subject: [PATCH 02/17] CJK 1.1: the character language reaches the shaper The character's language (as IDML names it) maps to a BCP 47 tag (designcraft_doc::language_tag) and is the shaping context's language on the harfrust buffer, so OpenType locl applies for every language in the list (Japanese, Chinese, Korean, Turkish, ...), not only Arabic, Persian, Urdu, Hebrew and English: other names used to reach the shaper as they were written, which it doesn't know, so their locl never applied. designcraft_fonts no longer maps names itself. The word-shaping cache is keyed by the tag. Chinese: Simplified and Chinese: Traditional join the language list (plain Chinese stays, tagged zh); Simplified Chinese takes curly quotes, Traditional Chinese corner brackets. --- ROADMAP.md | 2 +- crates/compose/src/shape.rs | 26 ++++---- crates/compose/src/tests.rs | 40 +++++++++++++ crates/doc/src/attrs.rs | 79 +++++++++++++++++++++++++ crates/engine/src/cmd/text.rs | 14 +++++ crates/fonts/src/lib.rs | 37 ++++++------ crates/ui-egui/src/panels/properties.rs | 2 + docs/cjk.md | 5 +- 8 files changed, 169 insertions(+), 36 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 805a796..6a9a7a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,7 +87,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. -- Language (Character panel ▸ Language, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, and typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, CJK 「…」). Hyphenation dictionaries for other languages aren't bundled yet. +- Language (Character panel ▸ Language, `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 “…”), and the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too. Hyphenation dictionaries for other languages aren't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index c39fb98..6188f42 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -614,10 +614,10 @@ fn shape_run_raw( } type WordMap = HashMap, Arc<[ShapedGlyph]>>; -type WordKey = (u32, bool, String); +type WordKey = (u32, bool, String, Option<&'static str>); -/// Shaped words (font units) by (face, caps, features), then text. Lookups take read locks and -/// misses are shaped outside any lock, so parallel composition doesn't serialize here. +/// Shaped words (font units) by (face, caps, features, language), then text. Lookups take read +/// locks and misses are shaped outside any lock, so parallel composition doesn't serialize here. static WORD_CACHE: RwLock>)>> = RwLock::new(Vec::new()); const WORD_CACHE_MAX: usize = 50_000; @@ -640,12 +640,13 @@ fn word_map(key: WordKey) -> Arc> { /// Shape `src` word by word through a cache (like a browser's word cache): the text is split /// after each U+0020 so repeated words are shaped once. Shaping does not cross word spaces in the /// scripts we lay out, and clusters stay byte offsets into `src`. -fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], caps: bool, context: designcraft_fonts::ShapeContext<'_>) -> Vec { +fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], lang: Option<&'static str>, caps: bool, around: (&str, &str)) -> Vec { let map = |c: char| if caps { c.to_uppercase().next().unwrap_or(c) } else { c }; if src.len() < 2 || !src.contains(' ') || src.chars().any(designcraft_fonts::is_rtl) { - return designcraft_fonts::shape_with_context(face, src, feats, map, context); + let (before, after) = around; + return designcraft_fonts::shape_with_context(face, src, feats, map, designcraft_fonts::ShapeContext { before, after, language: lang }); } - let words = word_map((face.id(), caps, format!("{feats:?}/{}", context.language))); + let words = word_map((face.id(), caps, format!("{feats:?}"), lang)); let pieces: Vec<&str> = src.split_inclusive(' ').collect(); let mut found: Vec>> = { let r = words.read().unwrap_or_else(|e| e.into_inner()); @@ -659,7 +660,7 @@ fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], caps: bool, conte pieces[k], feats, map, - designcraft_fonts::ShapeContext { language: context.language, ..Default::default() }, + designcraft_fonts::ShapeContext { language: lang, ..Default::default() }, ) .into(); fresh.push((pieces[k].into(), g.clone())); @@ -780,12 +781,11 @@ fn shape_segment( feats.extend(["vert", "vrt2"].iter().filter_map(|t| designcraft_fonts::feature(t))); } let space = face.advance(face.glyph_for(' ')) * k * hs; - let context = designcraft_fonts::ShapeContext { - before: if replacement.is_none() { text.get(..range.start).unwrap_or("") } else { "" }, - after: if replacement.is_none() { text.get(range.end..).unwrap_or("") } else { "" }, - language: &p.language, - }; - let shaped: Vec = shape_cached(face, src, &feats, caps, context); + // The language picks the font's localized forms (`locl`); the text around the run lets + // letters join across a change of style. + let lang = designcraft_doc::language_tag(&p.language); + let around = if replacement.is_none() { (text.get(..range.start).unwrap_or(""), text.get(range.end..).unwrap_or("")) } else { ("", "") }; + let shaped: Vec = shape_cached(face, src, &feats, lang, caps, around); let n = shaped.len(); let fref = FaceRef::of(face); for (gi, sg) in shaped.iter().enumerate() { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a168c15..e68846b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1428,3 +1428,43 @@ fn arabic_character_overrides_mirror_brackets_using_final_levels() { } } } + +#[test] +fn language_picks_localized_forms_through_the_word_cache() { + // Source Serif 4 has a Turkish `locl` form of `i`; the same words in English and Turkish + // (both shaped word by word through the cache) keep their own forms. + let text = "in in in in"; + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 500.0, 200.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(6..text.len(), |f| f.over.language = Some("Turkish".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let gid_at = |byte: usize| all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).find(|g| g.byte == byte).map(|g| g.gid).unwrap(); + assert_eq!(gid_at(0), gid_at(3), "English i"); + assert_eq!(gid_at(6), gid_at(9), "Turkish i"); + assert_ne!(gid_at(0), gid_at(6), "Turkish i takes its localized form"); + // A single word is shaped directly, in its language too. + let (mut d, sid, _) = doc_with("i", Rect::new(36.0, 36.0, 500.0, 200.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.language = Some("Turkish".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(all_lines(&cs)[0].glyphs[0].gid, gid_at(6), "Turkish i, shaped on its own"); +} + +#[test] +fn chinese_and_japanese_take_their_localized_forms() { + // Noto Sans CJK SC (craft-fonts) draws 直 differently for Japanese (`locl`). + let Some(noto) = designcraft_fonts::CRAFT_FONTS.iter().find(|f| f.family == "Noto Sans CJK SC") else { return }; + FontDb::global().add_font(noto.bytes.to_vec()); + let gid = |language: &str| { + let (mut d, sid, _) = doc_with("直", Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0.."直".len(), |f| { + f.over.font_family = Some(noto.family.into()); + f.over.language = Some(language.into()); + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let g = &all_lines(&cs)[0].glyphs[0]; + assert_eq!(g.face.family, noto.family); + g.gid + }; + let chinese = gid("Chinese: Simplified"); + assert_eq!(chinese, FontDb::global().face(noto.family, noto.style).glyph_for('直')); + assert_ne!(gid("Japanese"), chinese, "Japanese 直"); +} diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index bd94ae3..c56752a 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -190,6 +190,65 @@ impl Digits { } } +/// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese: +/// Simplified"), for the shaper's localized forms (`locl`). A variant we don't know falls back to +/// its language ("English: Australian" → `en`); an unknown language (or "[No Language]") has none. +pub fn language_tag(language: &str) -> Option<&'static str> { + let l = language.trim().to_ascii_lowercase(); + let variant = match l.as_str() { + "english: usa" => Some("en-US"), + "english: uk" => Some("en-GB"), + "english: canadian" => Some("en-CA"), + "german: swiss 2006 reform" | "german: swiss" => Some("de-CH"), + "french: canadian" => Some("fr-CA"), + "portuguese: brazilian" => Some("pt-BR"), + "norwegian: bokmål" | "norwegian: bokmal" => Some("nb"), + "norwegian: nynorsk" => Some("nn"), + "chinese: simplified" => Some("zh-Hans"), + "chinese: traditional" => Some("zh-Hant"), + _ => None, + }; + if variant.is_some() { + return variant; + } + Some(match l.split(':').next().unwrap_or("").trim() { + "english" => "en", + "german" => "de", + "french" => "fr", + "spanish" => "es", + "catalan" => "ca", + "italian" => "it", + "portuguese" => "pt", + "dutch" => "nl", + "danish" => "da", + "swedish" => "sv", + "norwegian" => "no", + "finnish" => "fi", + "polish" => "pl", + "czech" => "cs", + "slovak" => "sk", + "hungarian" => "hu", + "romanian" => "ro", + "croatian" => "hr", + "lithuanian" => "lt", + "greek" => "el", + "russian" => "ru", + "ukrainian" => "uk", + "bulgarian" => "bg", + "serbian" => "sr", + "macedonian" => "mk", + "turkish" => "tr", + "japanese" => "ja", + "korean" => "ko", + "chinese" => "zh", + "arabic" => "ar", + "persian" | "farsi" => "fa", + "urdu" => "ur", + "hebrew" => "he", + _ => return None, + }) +} + /// A tracked change on text. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -701,6 +760,26 @@ mod tests { assert_eq!(Digits::Hindi.map('x', ""), 'x'); } + #[test] + fn language_names_map_to_bcp47_tags() { + assert_eq!(language_tag("English: USA"), Some("en-US")); + assert_eq!(language_tag("English: Australian"), Some("en")); + assert_eq!(language_tag("Turkish"), Some("tr")); + assert_eq!(language_tag("Japanese"), Some("ja")); + assert_eq!(language_tag("Korean"), Some("ko")); + assert_eq!(language_tag("Chinese"), Some("zh")); + assert_eq!(language_tag("Chinese: Simplified"), Some("zh-Hans")); + assert_eq!(language_tag("Chinese: Traditional"), Some("zh-Hant")); + assert_eq!(language_tag("Arabic"), Some("ar")); + assert_eq!(language_tag("Persian"), Some("fa")); + assert_eq!(language_tag("Farsi"), Some("fa")); + assert_eq!(language_tag("Urdu"), Some("ur")); + assert_eq!(language_tag("Hebrew"), Some("he")); + assert_eq!(language_tag("[No Language]"), None); + assert_eq!(language_tag("Klingon"), None); + assert_eq!(language_tag(""), None); + } + #[test] fn merge_and_resolve() { let mut a = CharAttrs { size: Some(10.0), ..Default::default() }; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b5204b4..c1b10b9 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -499,6 +499,8 @@ pub fn quote_marks(language: &str) -> [char; 4] { ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}'] } else if l.starts_with("swedish") || l.starts_with("finnish") { ['\u{201D}', '\u{201D}', '\u{2019}', '\u{2019}'] + } else if l.starts_with("chinese: simplified") { + ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}'] } else if l.starts_with("japanese") || l.starts_with("chinese") { ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'] } else if l.starts_with("danish") { @@ -1275,6 +1277,18 @@ mod language_tests { let st = s.execute("spelling.check", &json!({"story": sid})).unwrap(); assert!(st.as_array().unwrap().is_empty(), "{st}"); } + + #[test] + fn cjk_languages_have_their_quotes_and_no_english_rules() { + use super::quote_marks; + assert_eq!(quote_marks("Chinese: Simplified"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); + assert_eq!(quote_marks("Chinese: Traditional"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("Chinese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("Japanese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + for l in ["Japanese", "Korean", "Chinese", "Chinese: Simplified", "Chinese: Traditional"] { + assert!(!designcraft_compose::is_english(l), "{l}: no English hyphenation or spelling"); + } + } } #[cfg(test)] diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 72c19ec..bda6987 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -11,7 +11,7 @@ mod fontdb; pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, base_style, bundled, system_font_dirs}; pub use harfrust::Feature; -use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; +use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer}; pub use kurbo::BezPath; use skrifa::MetadataProvider; use skrifa::instance::Size; @@ -113,23 +113,14 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range, bool, unicode_scri runs } +/// What surrounds a run being shaped: the text before and after it (so Arabic letters join across a +/// change of style) and its language, a BCP 47 tag such as `tr` or `zh-Hant` that picks the font's +/// localized forms (OpenType `locl`; `None` or a tag the shaper can't read: the font's defaults). #[derive(Clone, Copy, Default)] pub struct ShapeContext<'a> { pub before: &'a str, pub after: &'a str, - pub language: &'a str, -} - -/// Normalize the supported application language names while also accepting BCP 47 tags. -pub fn language_tag(language: &str) -> &str { - match language.split(':').next().unwrap_or(language).trim() { - "Arabic" => "ar", - "Persian" | "Farsi" => "fa", - "Urdu" => "ur", - "Hebrew" => "he", - "English" => "en", - _ => language, - } + pub language: Option<&'a str>, } /// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All @@ -140,6 +131,7 @@ pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(cha shape_with_context(face, text, features, map, ShapeContext::default()) } +/// [`shape`] `text` in its `context`: its language and the text around it. pub fn shape_with_context( face: &FontFace, text: &str, @@ -196,7 +188,7 @@ fn shape_dir( if let Ok(script) = script.short_name().parse() { buf.set_script(script); } - if let Ok(language) = language_tag(context.language).parse() { + if let Some(language) = context.language.and_then(|t| t.parse::().ok()) { buf.set_language(language); } buf.set_flags(harfrust::BufferFlags::PRODUCE_SAFE_TO_INSERT_TATWEEL); @@ -254,10 +246,6 @@ mod tests { assert_eq!(runs.len(), 2); assert_eq!(runs[0], (0..5, true, Script::Hebrew)); assert_eq!(runs[1], (5..text.len(), true, Script::Arabic)); - assert_eq!(language_tag("Arabic"), "ar"); - assert_eq!(language_tag("Farsi"), "fa"); - assert_eq!(language_tag("Urdu"), "ur"); - assert_eq!(language_tag("ar-SA"), "ar-SA"); } #[test] @@ -297,6 +285,17 @@ mod tests { assert_eq!(off.len(), 6); } + #[test] + fn language_picks_localized_forms() { + // Source Serif 4's `locl` has a Turkish `i`. + let face = FontDb::global().face(DEFAULT_FAMILY, "Regular"); + let gid = |language| shape_with_context(&face, "i", &[], |c| c, ShapeContext { language, ..Default::default() })[0].gid; + assert_eq!(gid(None), face.glyph_for('i')); + assert_eq!(gid(Some("en-US")), face.glyph_for('i')); + assert_ne!(gid(Some("tr")), face.glyph_for('i')); + assert_eq!(gid(Some("not a tag")), face.glyph_for('i')); + } + #[test] fn mapping_keeps_clusters() { let face = FontDb::global().face(DEFAULT_FAMILY, "Regular"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index bf3f0b3..b819bbf 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1206,6 +1206,8 @@ const LANGUAGES: &[&str] = &[ "Turkish", "Japanese", "Chinese", + "Chinese: Simplified", + "Chinese: Traditional", "Korean", "Arabic", "Hebrew", diff --git a/docs/cjk.md b/docs/cjk.md index 534b36c..24d40e0 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -47,9 +47,8 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built Known bugs to fix first: - Korean breaks between every syllable (it should break at spaces by default). -- Only Arabic, Persian, Urdu, Hebrew and English language names reach the shaper as languages - it knows, so `locl` never applies for the others (Japanese, Chinese, Korean, Turkish…); Chinese - and Korean text get Japanese glyph forms and the Japanese fallback font. +- Fallback is language-blind: Chinese and Korean text without a covering font get the Japanese + fallback font (and so Japanese glyph forms, whatever their language's `locl` asks for). - Vertical advances are the horizontal ones and upright glyphs turn about a guessed centre (`adv × 0.38`), so proportional and non-square glyphs sit wrong in vertical text. - A justified CJK line has no stretch except Single Word Justification. From 2da99b70e20d7b4723c40764238fe07cfaa285f5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 18:30:50 -0400 Subject: [PATCH 03/17] CJK 1.2: font fallback follows the language Characters the primary font lacks try a fallback chain by language before the language-blind search: Japanese (the craft-fonts Japanese faces first, Mincho first), Simplified Chinese (also plain Chinese), Traditional Chinese and Korean each list their macOS, Windows and Linux system families. Hangul, kana and Bopomofo pick their language's chain whatever the character language; ideographs and CJK punctuation follow it, and keep today's fallback without a CJK language. Chain families load from the system font catalog the first time they are needed. designcraft_fonts::testing (feature `testing`) builds synthetic fonts with a given family name and coverage for tests. --- ROADMAP.md | 2 +- crates/compose/Cargo.toml | 1 + crates/compose/src/ruby.rs | 2 +- crates/compose/src/shape.rs | 7 +- crates/compose/src/tests.rs | 27 +++++- crates/fonts/Cargo.toml | 4 + crates/fonts/src/fontdb.rs | 137 ++++++++++++++++++++++++++++- crates/fonts/src/lib.rs | 15 +++- crates/fonts/src/testing.rs | 84 ++++++++++++++++++ crates/fonts/src/tests_fallback.rs | 127 ++++++++++++++++++++++++++ docs/cjk.md | 4 +- 11 files changed, 396 insertions(+), 14 deletions(-) create mode 100644 crates/fonts/src/testing.rs create mode 100644 crates/fonts/src/tests_fallback.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6a9a7a9..6c0652f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,7 +87,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. -- Language (Character panel ▸ Language, `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 “…”), and the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too. Hyphenation dictionaries for other languages aren't bundled yet. +- Language (Character panel ▸ Language, `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: 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). Hyphenation dictionaries for other languages aren't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 075f19d..83ba818 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -20,4 +20,5 @@ regex = { workspace = true } miniz_oxide = "0.8" [dev-dependencies] +designcraft-fonts = { workspace = true, features = ["testing"] } proptest = { workspace = true } diff --git a/crates/compose/src/ruby.rs b/crates/compose/src/ruby.rs index d3b8cf1..06d57ba 100644 --- a/crates/compose/src/ruby.rs +++ b/crates/compose/src/ruby.rs @@ -45,7 +45,7 @@ fn kenten(b: &PlacedGlyph, size: f64, character: &str) -> Vec { let mut base = b.clone(); if let Some(c) = character.chars().next() && !base.face.covers(c) - && let Some(face) = designcraft_fonts::FontDb::global().fallback_for(c, base.face.id()) + && let Some(face) = designcraft_fonts::FontDb::global().fallback_for(c, base.face.id(), None) { base.face = designcraft_fonts::FaceRef::of(&face); } diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 6188f42..92e297f 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -466,6 +466,8 @@ fn shape_run_raw( out: &mut Vec, ) { let primary = db.face(&p.font_family, &p.font_style); + // The language picks the fallback for CJK characters the primary font lacks. + let lang = designcraft_doc::language_tag(&p.language); let strong = |c| match unicode_bidi::bidi_class(c) { unicode_bidi::BidiClass::AL => Some(true), unicode_bidi::BidiClass::L | unicode_bidi::BidiClass::R => Some(false), @@ -501,7 +503,8 @@ fn shape_run_raw( if d != c { flush(seg_start, i, &seg_face, out); seg_start = i + c.len_utf8(); - let face = if primary.covers(d) { primary.clone() } else { db.fallback_for(d, primary.id()).unwrap_or_else(|| primary.clone()) }; + let face = + if primary.covers(d) { primary.clone() } else { db.fallback_for(d, primary.id(), lang).unwrap_or_else(|| primary.clone()) }; shape_segment(db, text, i..i + c.len_utf8(), Some(d.encode_utf8(&mut [0; 4])), p, &face, auto_leading, style, out, sub.vertical); continue; } @@ -602,7 +605,7 @@ fn shape_run_raw( let face = if covered || is_mark(c) { if is_mark(c) { seg_face.clone() } else { primary.clone() } } else { - db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone()) + db.fallback_for(c, primary.id(), lang).unwrap_or_else(|| primary.clone()) }; if face.id() != seg_face.id() { flush(seg_start, i, &seg_face, out); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e68846b..a1261a3 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1258,7 +1258,7 @@ fn arabic_character_direction_override_survives_line_layout() { #[test] fn arabic_fallback_marks_stay_with_bases_and_custom_offsets_move_only_marks() { let db = designcraft_fonts::FontDb::global(); - let Some(face) = db.fallback_for('ب', db.face(designcraft_fonts::DEFAULT_FAMILY, "Regular").id()) else { return }; + let Some(face) = db.fallback_for('ب', db.face(designcraft_fonts::DEFAULT_FAMILY, "Regular").id(), None) else { return }; if !face.covers('ُ') { return; } @@ -1468,3 +1468,28 @@ fn chinese_and_japanese_take_their_localized_forms() { assert_eq!(chinese, FontDb::global().face(noto.family, noto.style).glyph_for('直')); assert_ne!(gid("Japanese"), chinese, "Japanese 直"); } + +#[test] +fn cjk_fallback_follows_the_language() { + use designcraft_fonts::testing::font_with; + // Stand-ins for Hiragino Mincho ProN (the Japanese chain's first system font) and Songti SC + // (first in the Simplified Chinese chain); the real ones when installed. + let db = designcraft_fonts::FontDb::global(); + db.add_font(font_with("Hiragino Mincho ProN", &['直']).unwrap()); + db.add_font(font_with("Songti SC", &['直']).unwrap()); + // (face of the Latin letter, face of the ideograph) + let faces_of = |language: &str| { + let (mut d, sid, _) = doc_with("a直", Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..4, |f| f.over.language = Some(language.into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let face_at = |byte: usize| all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).find(|g| g.byte == byte).map(|g| g.face).unwrap(); + (face_at(0), face_at(1)) + }; + assert_eq!(faces_of("Chinese: Simplified").1.family, "Songti SC"); + // Japanese: the craft-fonts faces lead the chain (Mincho first), then the system fonts. + let japanese = designcraft_fonts::japanese_document_fonts().first().map_or("Hiragino Mincho ProN", |f| f.family); + assert_eq!(faces_of("Japanese").1.family, japanese); + // Without a CJK language, the language-blind search. + let (primary, han) = faces_of("English: USA"); + assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone())); +} diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index 6542bc0..d30d210 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -8,6 +8,10 @@ description = "DesignCraft fonts: font database (bundled OFL + system), metrics, [lints] workspace = true +[features] +# Synthetic fonts for other crates' tests (`designcraft_fonts::testing`). +testing = [] + [dependencies] kurbo = { workspace = true } skrifa = { workspace = true } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 7347691..c5f0271 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -246,6 +246,8 @@ pub struct FontDb { struct SysFallback { enabled: bool, misses: std::collections::HashSet, + /// CJK chain families already looked up in the catalog (loaded, or not installed). + chain_tried: std::collections::HashSet<&'static str>, } /// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji. @@ -278,6 +280,108 @@ const SYSTEM_FALLBACKS: &[&str] = &[ "Noto Color Emoji", ]; +/// CJK fallback chains: the families tried first (user-added, else installed) for characters of +/// each language that the primary font lacks. Each lists macOS, then Windows, then Linux / +/// cross-platform families, serif first; a platform only has its own installed. Japanese tries +/// the craft-fonts Japanese faces before these ([`japanese_chain`]). +const JAPANESE_FALLBACKS: &[&str] = &[ + "Hiragino Mincho ProN", + "YuMincho", + "Hiragino Sans", + "Yu Mincho", + "MS Mincho", + "Yu Gothic", + "Noto Serif CJK JP", + "Source Han Serif JP", + "Noto Serif JP", + "Noto Sans CJK JP", + "Source Han Sans JP", +]; +const SIMPLIFIED_CHINESE_FALLBACKS: &[&str] = &[ + "Songti SC", + "STSong", + "PingFang SC", + "SimSun", + "Microsoft YaHei", + "Noto Serif CJK SC", + "Source Han Serif SC", + "Noto Serif SC", + "Noto Sans CJK SC", + "Source Han Sans SC", +]; +const TRADITIONAL_CHINESE_FALLBACKS: &[&str] = &[ + "Songti TC", + "PingFang TC", + "PMingLiU", + "MingLiU", + "Microsoft JhengHei", + "Noto Serif CJK TC", + "Source Han Serif TC", + "Noto Serif TC", + "Noto Sans CJK TC", + "Source Han Sans TC", +]; +const KOREAN_FALLBACKS: &[&str] = &[ + "AppleMyungjo", + "Apple SD Gothic Neo", + "Batang", + "Malgun Gothic", + "Noto Serif CJK KR", + "Source Han Serif K", + "Noto Serif KR", + "Noto Sans CJK KR", + "Source Han Sans K", +]; + +/// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order +/// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`]. +fn japanese_chain() -> &'static [&'static str] { + static CHAIN: std::sync::OnceLock> = std::sync::OnceLock::new(); + CHAIN.get_or_init(|| { + let mut chain: Vec<&'static str> = Vec::new(); + for f in crate::japanese_document_fonts() { + if !chain.contains(&f.family) { + chain.push(f.family); + } + } + chain.extend_from_slice(JAPANESE_FALLBACKS); + chain + }) +} + +/// The CJK fallback chain for `c` in `language` (a BCP 47 tag). The script decides where it is +/// unambiguous (Hangul is Korean, kana Japanese, Bopomofo Traditional Chinese); ideographs and CJK +/// punctuation follow the language, and have no chain without a CJK one. +fn cjk_chain(c: char, language: Option<&str>) -> Option<&'static [&'static str]> { + match c as u32 { + 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC => return Some(KOREAN_FALLBACKS), + 0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F | 0x1B000..=0x1B16F => return Some(japanese_chain()), + 0x3100..=0x312F | 0x31A0..=0x31BF => return Some(TRADITIONAL_CHINESE_FALLBACKS), + // Radicals, CJK symbols and punctuation, kanbun, strokes, enclosed and compatibility + // characters, ideographs, compatibility and vertical forms, full-width forms. + 0x2E80..=0x2FDF + | 0x3000..=0x303F + | 0x3190..=0x319F + | 0x31C0..=0x31EF + | 0x3200..=0x33FF + | 0x3400..=0x4DBF + | 0x4E00..=0x9FFF + | 0xF900..=0xFAFF + | 0xFE10..=0xFE1F + | 0xFE30..=0xFE4F + | 0xFF00..=0xFFEF + | 0x20000..=0x3FFFF => {} + _ => return None, + } + match language? { + "ja" => Some(japanese_chain()), + "ko" => Some(KOREAN_FALLBACKS), + "zh" | "zh-Hans" => Some(SIMPLIFIED_CHINESE_FALLBACKS), + "zh-Hant" => Some(TRADITIONAL_CHINESE_FALLBACKS), + _ => None, + } +} + static NEXT_ID: AtomicU32 = AtomicU32::new(1); const OUTLINE_CACHE_MAX: usize = 50_000; @@ -796,9 +900,16 @@ impl FontDb { .map(|f| (*f).clone()) } - /// First face (fallback family first, then load order) that covers `c`; on native, system + /// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when + /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the + /// first face (fallback family first, then load order) that covers `c`. On native, system /// fonts are cataloged and loaded lazily the first time no loaded face covers a character. - pub fn fallback_for(&self, c: char, exclude: u32) -> Option> { + pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option> { + if let Some(chain) = cjk_chain(c, language) + && let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude)) + { + return Some(f); + } if let Some(f) = self.loaded_fallback(c, exclude) { return Some(f); } @@ -809,6 +920,23 @@ impl FontDb { None } + /// The face of `family` (regular first) that covers `c`, loading the installed family the + /// first time a chain asks for it (native, while the system fallback is on). + fn chain_face(&self, family: &'static str, c: char, exclude: u32) -> Option> { + #[cfg(not(target_arch = "wasm32"))] + if !self.is_loaded(family) { + // Held while loading, so a parallel lookup waits for the font instead of passing it by. + let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + if sys.enabled && sys.chain_tried.insert(family) { + self.load_cataloged(family); + } + } + let faces = self.read_faces(); + let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(family)).collect(); + order.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32)); + order.into_iter().find(|f| f.covers(c)).cloned() + } + fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { let faces = self.read_faces(); let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); @@ -903,3 +1031,8 @@ impl OutlinePen for FlipPen { #[cfg(not(target_arch = "wasm32"))] #[path = "tests_sysfonts.rs"] mod tests_sysfonts; + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +#[path = "tests_fallback.rs"] +mod tests_fallback; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index bda6987..9b3f0fb 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -8,6 +8,8 @@ #![forbid(unsafe_code)] mod fontdb; +#[cfg(any(test, feature = "testing"))] +pub mod testing; pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, base_style, bundled, system_font_dirs}; pub use harfrust::Feature; @@ -382,10 +384,13 @@ mod tests { assert_eq!(horizontal.len(), vertical.len()); assert!(horizontal.iter().zip(&vertical).any(|(h, v)| h.gid != v.gid), "{} has vertical forms", cf.family); } - // Japanese in a Latin face falls back to a craft-fonts Mincho, without system fonts. + // Japanese in a Latin face falls back to a craft-fonts Mincho, without system fonts, in + // Japanese text (the Japanese chain) and without a language (the first loaded face). let latin = db.face(DEFAULT_FAMILY, "Regular"); - let fb = db.fallback_for('語', latin.id()).unwrap(); - assert!(fb.family.contains("Mincho"), "{fb:?}"); + for language in [Some("ja"), None] { + let fb = db.fallback_for('語', latin.id(), language).unwrap(); + assert!(fb.family.contains("Mincho"), "{language:?}: {fb:?}"); + } } #[test] @@ -398,7 +403,9 @@ mod tests { if CRAFT_FONTS.is_empty() { assert!(japanese_fonts().next().is_none() && japanese_document_fonts().is_empty()); let latin = db.face(DEFAULT_FAMILY, "Regular"); - assert!(db.fallback_for('語', latin.id()).is_none(), "no bundled Japanese font"); + for language in [Some("ja"), None] { + assert!(db.fallback_for('語', latin.id(), language).is_none(), "no bundled Japanese font: {language:?}"); + } } } } diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs new file mode 100644 index 0000000..c1ccae8 --- /dev/null +++ b/crates/fonts/src/testing.rs @@ -0,0 +1,84 @@ +//! Synthetic fonts for tests (here and in dependent crates, through the `testing` feature). + +use skrifa::MetadataProvider; + +/// A font named `family` (style Regular) that maps exactly `chars`, all to one glyph: the bundled +/// Source Sans 3 Regular with its `name` and `cmap` tables replaced. Stands in for a font that +/// isn't installed (a system CJK font on a machine without it). `None` only if the bundled font +/// can't be read. +pub fn font_with(family: &str, chars: &[char]) -> Option> { + let base = *crate::bundled().first()?; + let gid = skrifa::FontRef::new(base).ok()?.charmap().map('X')?.to_u32(); + let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes); + let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + + let mut tables: Vec<([u8; 4], Vec)> = Vec::new(); + for r in 0..usize::from(be16(base, 4)?) { + let rec = 12 + r * 16; + let tag: [u8; 4] = base.get(rec..rec + 4)?.try_into().ok()?; + let (offset, len) = (be32(base, rec + 8)? as usize, be32(base, rec + 12)? as usize); + if &tag != b"name" && &tag != b"cmap" { + tables.push((tag, base.get(offset..offset.checked_add(len)?)?.to_vec())); + } + } + + // `name`: format 0, family (1) and subfamily (2), Windows Unicode English. + let strings: Vec> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect(); + let mut name = Vec::new(); + for v in [0u16, 2, 6 + 2 * 12] { + name.extend_from_slice(&v.to_be_bytes()); + } + let mut at = 0usize; + for (id, s) in [1u16, 2].iter().zip(&strings) { + for v in [3u16, 1, 0x409, *id, u16::try_from(s.len()).ok()?, u16::try_from(at).ok()?] { + name.extend_from_slice(&v.to_be_bytes()); + } + at += s.len(); + } + strings.iter().for_each(|s| name.extend_from_slice(s)); + tables.push((*b"name", name)); + + // `cmap`: one format 12 subtable (Windows, full Unicode), one group per character. + let mut cps: Vec = chars.iter().map(|c| *c as u32).collect(); + cps.sort_unstable(); + cps.dedup(); + let sub_len = u32::try_from(16 + 12 * cps.len()).ok()?; + let mut cmap = Vec::new(); + for v in [0u16, 1, 3, 10] { + cmap.extend_from_slice(&v.to_be_bytes()); + } + cmap.extend_from_slice(&12u32.to_be_bytes()); + cmap.extend_from_slice(&12u16.to_be_bytes()); + cmap.extend_from_slice(&0u16.to_be_bytes()); + for v in [sub_len, 0, u32::try_from(cps.len()).ok()?] { + cmap.extend_from_slice(&v.to_be_bytes()); + } + for cp in cps { + for v in [cp, cp, gid] { + cmap.extend_from_slice(&v.to_be_bytes()); + } + } + tables.push((*b"cmap", cmap)); + + // The file: table directory (sorted by tag), then the tables, each 4-byte aligned. + tables.sort_by_key(|t| t.0); + let n = u16::try_from(tables.len()).ok()?; + let pow = 1u16 << (15 - n.leading_zeros().min(15)); + let mut out = base.get(0..4)?.to_vec(); + for v in [n, pow * 16, pow.trailing_zeros() as u16, n * 16 - pow * 16] { + out.extend_from_slice(&v.to_be_bytes()); + } + let mut offset = 12 + 16 * tables.len(); + for (tag, data) in &tables { + out.extend_from_slice(tag); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&u32::try_from(offset).ok()?.to_be_bytes()); + out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_be_bytes()); + offset += data.len().next_multiple_of(4); + } + for (_, data) in &tables { + out.extend_from_slice(data); + out.resize(out.len().next_multiple_of(4), 0); + } + Some(out) +} diff --git a/crates/fonts/src/tests_fallback.rs b/crates/fonts/src/tests_fallback.rs new file mode 100644 index 0000000..c1f84af --- /dev/null +++ b/crates/fonts/src/tests_fallback.rs @@ -0,0 +1,127 @@ +//! CJK fallback follows the character's language (and the script where it is unambiguous). + +use super::*; +use crate::testing::font_with; + +const HAN: char = '直'; +const HANGUL: char = '한'; +const KANA: char = 'あ'; +const BOPOMOFO: char = 'ㄅ'; + +/// A database of the bundled fonts (and the craft-fonts Japanese faces, when built with them) +/// only, no system fonts, plus `fonts` as (family, chars). +fn db_with(fonts: &[(&str, &[char])]) -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + for (family, chars) in fonts { + assert_eq!(db.add_font(font_with(family, chars).unwrap()), 1, "{family}"); + } + db +} + +fn family(db: &FontDb, c: char, language: Option<&str>) -> Option { + db.fallback_for(c, 0, language).map(|f| f.family.clone()) +} + +/// The families the Japanese chain finds, in order: the craft-fonts Japanese faces (none without +/// craft-fonts), then `installed`, the stand-ins for system fonts (in chain order). +fn japanese_chain_with(installed: &[&'static str]) -> Vec<&'static str> { + let mut v: Vec<&'static str> = Vec::new(); + for f in crate::japanese_document_fonts() { + if !v.contains(&f.family) { + v.push(f.family); + } + } + v.extend_from_slice(installed); + v +} + +/// What the language-blind search finds first for CJK text when `added` is the first added font +/// covering it: the craft-fonts Japanese faces load ahead of added fonts. +fn first_loaded(added: &'static str) -> &'static str { + japanese_chain_with(&[added])[0] +} + +#[test] +fn ideographs_follow_the_language() { + let db = db_with(&[("Songti SC", &[HAN]), ("Songti TC", &[HAN]), ("AppleMyungjo", &[HAN]), ("Hiragino Mincho ProN", &[HAN])]); + assert_eq!(family(&db, HAN, Some("zh-Hans")).as_deref(), Some("Songti SC")); + assert_eq!(family(&db, HAN, Some("zh")).as_deref(), Some("Songti SC"), "plain Chinese is Simplified"); + assert_eq!(family(&db, HAN, Some("zh-Hant")).as_deref(), Some("Songti TC")); + assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some("AppleMyungjo")); + let ja = japanese_chain_with(&["Hiragino Mincho ProN"])[0]; + assert_eq!(family(&db, HAN, Some("ja")).as_deref(), Some(ja), "the craft-fonts faces lead the Japanese chain, then system fonts"); + // CJK punctuation follows the language too. + let db = db_with(&[("Other CJK", &['、']), ("Songti SC", &['、'])]); + assert_eq!(family(&db, '、', Some("zh-Hans")).as_deref(), Some("Songti SC")); + assert_eq!(family(&db, '、', None).as_deref(), Some(first_loaded("Other CJK"))); +} + +#[test] +fn ideographs_without_a_cjk_language_fall_back_as_before() { + let db = db_with(&[("Other CJK", &[HAN]), ("Songti SC", &[HAN]), ("AppleMyungjo", &[HAN])]); + for language in [None, Some("en-US"), Some("tr"), Some("not a tag")] { + assert_eq!(family(&db, HAN, language).as_deref(), Some(first_loaded("Other CJK")), "{language:?}"); + } +} + +#[test] +fn the_script_decides_where_it_is_unambiguous() { + // A non-chain font loaded first would win the language-blind search. + let db = db_with(&[ + ("Other CJK", &[HANGUL, KANA, BOPOMOFO]), + ("AppleMyungjo", &[HANGUL]), + ("Songti TC", &[BOPOMOFO]), + ("Yu Mincho", &[KANA]), + ("Hiragino Mincho ProN", &[KANA]), + ]); + for language in [None, Some("en-US"), Some("ja"), Some("zh-Hans")] { + assert_eq!(family(&db, HANGUL, language).as_deref(), Some("AppleMyungjo"), "Hangul is Korean: {language:?}"); + assert_eq!(family(&db, BOPOMOFO, language).as_deref(), Some("Songti TC"), "Bopomofo is Traditional Chinese: {language:?}"); + } + // Hiragino Mincho ProN comes before Yu Mincho in the chain, whatever order they loaded in. + let chain = japanese_chain_with(&["Hiragino Mincho ProN", "Yu Mincho"]); + for language in [None, Some("zh-Hans"), Some("ko")] { + assert_eq!(family(&db, KANA, language).as_deref(), Some(chain[0]), "kana is Japanese: {language:?}"); + } + // Past the faces the chain can't use (here its first, as the primary font), the next. + let first = db.face(chain[0], "Regular"); + assert_eq!(db.fallback_for(KANA, first.id(), None).map(|f| f.family.clone()).as_deref(), Some(chain[1])); +} + +#[test] +fn without_a_chain_font_the_fallback_is_unchanged() { + let db = db_with(&[("Other CJK", &[HANGUL])]); + // Without craft-fonts nothing covers the ideograph; with it, its first Japanese face does. + let craft = japanese_chain_with(&[]).first().copied(); + for language in ["zh-Hans", "zh-Hant", "ko", "ja"] { + assert_eq!(family(&db, HAN, Some(language)).as_deref(), craft, "{language}"); + } + assert_eq!(family(&db, HANGUL, Some("ko")).as_deref(), Some("Other CJK")); + assert_eq!(family(&db, BOPOMOFO, Some("zh-Hant")), None, "nothing covers it"); +} + +#[test] +fn chain_fonts_load_from_the_font_folders_when_first_needed() { + let dir = std::env::temp_dir().join(format!("dc-cjk-fallback-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("songti.ttf"), font_with("Songti SC", &[HAN]).unwrap()).unwrap(); + std::fs::write(dir.join("myungjo.ttf"), font_with("AppleMyungjo", &[HAN]).unwrap()).unwrap(); + // An added font covers the ideograph, so the language-blind search never scans the folder. + let other = || font_with("Other CJK", &[HAN]).unwrap(); + + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.add_font(other()); + assert_eq!(family(&db, HAN, None).as_deref(), Some(first_loaded("Other CJK"))); + assert!(!db.is_loaded("Songti SC"), "no language, no chain font loaded"); + assert_eq!(family(&db, HAN, Some("zh-Hans")).as_deref(), Some("Songti SC")); + assert!(!db.is_loaded("AppleMyungjo"), "only what the chain needed"); + assert_eq!(family(&db, HAN, None).as_deref(), Some(first_loaded("Other CJK")), "once loaded, still not used without the language"); + + // With the system fallback off, installed chain fonts aren't loaded. + let db = FontDb::with_font_dirs(vec![dir]); + db.add_font(other()); + db.set_system_fallback(false); + assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some(first_loaded("Other CJK"))); + assert!(!db.is_loaded("AppleMyungjo")); +} diff --git a/docs/cjk.md b/docs/cjk.md index 24d40e0..483c67c 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -41,14 +41,12 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Ruby | group ruby only, fixed 50 % size, no options, doesn't affect leading | | Kenten | the IDML kinds and a custom character, drawn as characters; no position, size, alignment, colour or font | | Composite fonts | done: model, `style.compositeFont.*`, shaping, IDML, font list, replace and Preflight; no dialog | -| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; language-blind fallback; no per-document fonts | +| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; fallback by language (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); no per-document fonts | | Everything else below | missing | Known bugs to fix first: - Korean breaks between every syllable (it should break at spaces by default). -- Fallback is language-blind: Chinese and Korean text without a covering font get the Japanese - fallback font (and so Japanese glyph forms, whatever their language's `locl` asks for). - Vertical advances are the horizontal ones and upright glyphs turn about a guessed centre (`adv × 0.38`), so proportional and non-square glyphs sit wrong in vertical text. - A justified CJK line has no stretch except Single Word Justification. From 832939691c78592149521cffc90c0b9b4e7d2b65 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 14:14:17 -0400 Subject: [PATCH 04/17] CJK 1.3: documents bring their own fonts Opening a .designcraft or .idml file from disk (file.open, file.openIdml, book chapters, recovered documents) loads the fonts in the "Document Fonts" folder beside it. Their faces come before installed faces of the same family and style. The files are untrusted: at most 200 are read, 256 MB each (full CJK collections fit) and 512 MB in all; files over a limit by their listed length are skipped unread, and damaged, oversized and surplus files are listed in the open result's warnings. Document fonts stay loaded for the session (the font database is process-wide). Faces record their source (bundled, installed file, document file, memory); font.list reports it. File > Package copies the font files the document uses into the package's Document Fonts folder unless OS/2 fsType restricts the licence, and the report says which fonts weren't copied and why. --- ROADMAP.md | 3 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/book.rs | 2 + crates/engine/src/cmd/file.rs | 108 +++++++++++++++++- crates/engine/src/cmd/fonts.rs | 14 ++- crates/engine/src/cmd/interchange.rs | 8 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/package.rs | 99 ++++++++++++++++- crates/engine/src/recovery.rs | 4 + crates/fonts/src/fontdb.rs | 160 +++++++++++++++++++++++++-- crates/fonts/src/lib.rs | 5 +- crates/fonts/src/testing.rs | 10 ++ crates/fonts/src/tests_docfonts.rs | 119 ++++++++++++++++++++ docs/cjk.md | 2 +- 14 files changed, 512 insertions(+), 24 deletions(-) create mode 100644 crates/fonts/src/tests_docfonts.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6c0652f..1fd114e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,7 +146,8 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded. - Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML). - Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name). -- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, an IDML copy, an optional PDF and a report (fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). +- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files it uses in a Document Fonts folder (except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). +- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts stay loaded until DesignCraft quits, available to every open document; on the web there is no folder to read. - Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4779435..ff02d62 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -30,6 +30,7 @@ regex = { workspace = true } quick-xml = { workspace = true } [dev-dependencies] +designcraft-fonts = { workspace = true, features = ["testing"] } zip = { workspace = true } hayro-syntax = "0.7.2" hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index df4bfc9..0d2f5eb 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -44,6 +44,8 @@ fn load(path: &str) -> Result { let _ = path; vec![] }; + // Skipped font files are logged; a chapter opens without them. + let _ = super::file::load_document_fonts(path); if path.to_lowercase().ends_with(".idml") { return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 98feab4..116fa0e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -17,7 +17,9 @@ pub fn specs() -> Vec { always, file_new), cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} — .designcraft or .idml", always, file_open), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), + "{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}", + always, file_open), cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save), @@ -159,8 +161,9 @@ fn file_open(s: &mut Session, p: &Value) -> Result { } let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let d = from_bytes(&bytes)?; + let (faces, warnings) = load_document_fonts(path); let i = s.add_document(DocState::new(d, Some(path.to_string()))); - Ok(json!({"index": i})) + Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) } #[cfg(target_arch = "wasm32")] { @@ -169,6 +172,23 @@ fn file_open(s: &mut Session, p: &Value) -> Result { } } +/// Load the fonts in the `Document Fonts` folder beside the document file at `path` → (faces +/// loaded, a warning for each font file skipped). Nothing on the web (no folders). +pub(crate) fn load_document_fonts(path: &str) -> (usize, Vec) { + #[cfg(not(target_arch = "wasm32"))] + { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + let Some(folder) = std::path::Path::new(path).parent().map(|d| d.join(DOCUMENT_FONTS_FOLDER)) else { return (0, Vec::new()) }; + let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder); + (r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect()) + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + (0, Vec::new()) + } +} + fn file_open_bytes(s: &mut Session, p: &Value) -> Result { let b = base64_decode(str_param(p, "base64").unwrap_or("")); // IDML packages are recognised by their stored `mimetype` first entry. @@ -656,3 +676,87 @@ mod layout_place_tests { assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty"); } } + +#[cfg(test)] +mod document_fonts_tests { + use std::path::{Path, PathBuf}; + + use designcraft_fonts::testing::font_with; + use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FontDb}; + use serde_json::{Value, json}; + + use crate::Session; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(DOCUMENT_FONTS_FOLDER)).unwrap(); + dir + } + + /// A document whose text is set in `family`, written to `path` (`.idml`: as IDML). + fn write_document(path: &Path, family: &str) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 5})).unwrap(); + s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); + if path.extension().is_some_and(|e| e == "idml") { + std::fs::write(path, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + } else { + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + } + } + + fn font_entry(s: &mut Session, family: &str) -> Value { + let l = s.execute("font.list", &json!({})).unwrap(); + l.as_array().unwrap().iter().find(|f| f["family"] == family).cloned().unwrap_or_else(|| panic!("{family} in {l}")) + } + + #[test] + fn opening_a_document_loads_its_document_fonts() { + const FAMILY: &str = "DocFont Open Test"; + let dir = temp_dir("designcraft"); + let fonts = dir.join(DOCUMENT_FONTS_FOLDER); + std::fs::write(fonts.join("open.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + std::fs::write(fonts.join("broken.otf"), b"not a font").unwrap(); + let path = dir.join("Brochure.designcraft"); + write_document(&path, FAMILY); + assert!(!FontDb::global().has_family(FAMILY)); + + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert!(r["warnings"].to_string().contains("broken.otf"), "a damaged font is reported, not fatal: {r}"); + let f = font_entry(&mut s, FAMILY); + assert_eq!((f["missing"].clone(), f["source"].clone()), (json!(false), json!("document")), "{f}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn opening_an_idml_file_loads_its_document_fonts() { + const FAMILY: &str = "DocFont IDML Test"; + let dir = temp_dir("idml"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("idml.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); + let path = dir.join("Brochure.idml"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_document_without_the_folder_opens_as_before() { + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Plain.designcraft"); + write_document(&path, designcraft_fonts::DEFAULT_FAMILY); + let r = Session::new().execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!((r["documentFonts"].clone(), r["warnings"].clone()), (json!(0), json!([])), "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 1ae53f3..65c6623 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use designcraft_doc::{CharAttrs, Document, Story}; +use designcraft_fonts::FontSource; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; @@ -12,7 +13,7 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ - cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing}] (missing first)", has_doc, |s, _| { + cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing)}] (missing first)", has_doc, |s, _| { Ok(Value::Array(list(&s.doc()?.doc))) }), cmd!( @@ -73,7 +74,16 @@ fn list(d: &Document) -> Vec { .map(|((family, style), n)| { let missing = !db.has_family(&family); let style_missing = !missing && !db.styles(&family).iter().any(|s| s.eq_ignore_ascii_case(&style)); - (missing || style_missing, json!({"family": family, "style": style, "characters": n, "missing": missing, "styleMissing": style_missing})) + let source = (!missing).then(|| match db.face(&family, &style).source { + FontSource::Bundled => "bundled", + FontSource::Installed(_) => "installed", + FontSource::Document(_) => "document", + FontSource::Memory => "added", + }); + ( + missing || style_missing, + json!({"family": family, "style": style, "characters": n, "missing": missing, "styleMissing": style_missing, "source": source}), + ) }) .collect(); out.sort_by_key(|(m, _)| !*m); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 8bc294f..519bd24 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { "{path?, embedImages?: true} — writes an IDML package to `path`, or returns {base64} without a path", has_doc, export_idml), cmd!(noundo "file.openIdml", "Open IDML", [], None, - "{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder)", + "{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings}", always, open_idml), ] } @@ -71,7 +71,11 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { if let Some(n) = name { d.title = n; } + let (faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) { + Some(path) => super::file::load_document_fonts(path), + None => (0, Vec::new()), + }; // Never save over the .idml with the native format: the document starts unsaved. let i = s.add_document(DocState::new(d, None)); - Ok(json!({"index": i})) + Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9414735..6a5147c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -297,5 +297,6 @@ pub fn file_bytes(d: &designcraft_doc::Document) -> Vec { pub fn file_from(b: &[u8]) -> Result { file::from_bytes(b) } +pub(crate) use file::load_document_fonts; pub use file::{base64_decode, base64_encode, from_bytes, to_bytes}; pub(crate) use place_text::autoflow as place_text_autoflow; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index a0b3b46..a324f3b 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use designcraft_doc::{AssetId, Document}; +use designcraft_fonts::{FontFace, FontSource}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; @@ -16,7 +17,7 @@ pub fn specs() -> Vec { "Package…", ["File"], None, - "{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, an IDML copy, an optional PDF and a report → {dir, files, report}", + "{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files it uses in Document Fonts/ (unless their licence restricts it), an IDML copy, an optional PDF and a report → {dir, files, report}", has_doc, package ), @@ -36,6 +37,11 @@ pub fn specs() -> Vec { fn link_name(name: &str, id: AssetId, taken: &mut Vec) -> String { let base = Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("asset-{}", id.0)); + unique_name(base, taken) +} + +/// `base`, or `base` numbered ("logo 2.png"), unique among `taken` (case-insensitively). +fn unique_name(base: String, taken: &mut Vec) -> String { let (stem, ext) = match base.rsplit_once('.') { Some((s, e)) => (s.to_string(), format!(".{e}")), None => (base.clone(), String::new()), @@ -74,6 +80,36 @@ fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Do Ok((out, written)) } +/// Copy the font file of `face` into `dir` (created when first needed) unless its licence +/// restricts it or it is already there (`copied`: the source files copied) → what the package +/// report adds after the font's name (nothing when copied). +fn copy_font(face: &FontFace, dir: &Path, copied: &mut Vec, taken: &mut Vec, files: &mut Vec) -> String { + let from = match &face.source { + FontSource::Bundled => return " — included with DesignCraft".into(), + FontSource::Memory => return " — not copied: no font file".into(), + FontSource::Installed(p) | FontSource::Document(p) => p, + }; + if face.restricted_licence() { + return " — not copied: its licence doesn't allow it".into(); + } + if copied.contains(from) { + return String::new(); + } + let name = from.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "font".into()); + let to = dir.join(unique_name(name, taken)); + // Packaging into the folder the font is in: it is there already (and copying a file onto + // itself empties it on some systems). + let same = std::fs::canonicalize(&to).is_ok_and(|t| std::fs::canonicalize(from).is_ok_and(|f| f == t)); + match std::fs::create_dir_all(dir).and_then(|_| if same { Ok(0) } else { std::fs::copy(from, &to) }) { + Ok(_) => { + copied.push(from.clone()); + files.push(to); + String::new() + } + Err(e) => format!(" — not copied: {e}"), + } +} + fn package(s: &mut Session, p: &Value) -> Result { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?); let st = s.doc()?; @@ -86,6 +122,17 @@ fn package(s: &mut Session, p: &Value) -> Result { let doc_path = dir.join(format!("{title}.designcraft")); std::fs::write(&doc_path, super::to_bytes(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", doc_path.display())))?; files.insert(0, doc_path); + // The font files, and what the report says about each font. + let db = designcraft_fonts::FontDb::global(); + let (fonts_dir, mut copied, mut taken) = (dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER), Vec::new(), Vec::new()); + let mut font_lines = Vec::new(); + for f in fonts.as_array().into_iter().flatten() { + let (family, style) = (f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or("")); + let missing = f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false); + let note = + if missing { " — MISSING".to_string() } else { copy_font(&db.face(family, style), &fonts_dir, &mut copied, &mut taken, &mut files) }; + font_lines.push(format!(" {family} {style}{note}\n")); + } if p.get("idml").and_then(Value::as_bool).unwrap_or(true) { let path = dir.join(format!("{title}.idml")); std::fs::write(&path, designcraft_idml::export_idml(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; @@ -103,10 +150,7 @@ fn package(s: &mut Session, p: &Value) -> Result { r += &format!("Instructions\n{t}\n\n"); } r += "Fonts\n"; - for f in fonts.as_array().into_iter().flatten() { - let missing = f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false); - r += &format!(" {} {}{}\n", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), if missing { " — MISSING" } else { "" }); - } + r += &font_lines.concat(); r += "\nLinks\n"; for l in links.as_array().into_iter().flatten() { r += &format!(" {} ({})\n", l["name"].as_str().unwrap_or(""), l["status"].as_str().unwrap_or("")); @@ -171,4 +215,49 @@ mod tests { assert!(s.doc().unwrap().doc.assets.values().next().unwrap().link.as_deref().unwrap().contains("Copies")); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn package_copies_the_fonts_their_licences_allow() { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + use designcraft_fonts::testing::{font_with, with_fs_type}; + const FREE: &str = "DocFont Package Free"; + const RESTRICTED: &str = "DocFont Package Restricted"; + let dir = std::env::temp_dir().join(format!("dc-package-fonts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap(); + std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap(); + // Text in both fonts, and in the bundled default font. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Free Restricted Default"})).unwrap(); + for (a, b, family) in [(0, 4, FREE), (5, 15, RESTRICTED)] { + s.execute("text.select", &json!({"story": r["story"], "anchor": a, "focus": b})).unwrap(); + s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); + } + let src = dir.join("src").join("Fonts.designcraft"); + s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap(); + let mut s = Session::new(); + assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 2); + + let out = dir.join("Pkg"); + let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); + let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); + assert!(packed_fonts.join("free.ttf").exists()); + assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out"); + assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]); + let report = r["report"].as_str().unwrap(); + assert!(report.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); + assert!(report.contains(&format!("{FREE} Regular\n")), "{report}"); + assert!(report.contains(&format!("{} Regular — included with DesignCraft", designcraft_fonts::DEFAULT_FAMILY)), "{report}"); + // The packaged document opens with its fonts. + let doc = r["files"][0].as_str().unwrap().to_string(); + assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 1); + // Packaging into the folder the fonts come from leaves them whole. + let len = std::fs::metadata(fonts.join("free.ttf")).unwrap().len(); + s.execute("file.package", &json!({"dir": dir.join("src").to_string_lossy(), "idml": false})).unwrap(); + assert_eq!(std::fs::metadata(fonts.join("free.ttf")).unwrap().len(), len); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 8b24afe..3c6bf86 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -91,6 +91,10 @@ pub fn open(s: &mut Session, dir: &Path) -> Result> { Err(_) => continue, }; let path = meta.get("path").and_then(Value::as_str).map(str::to_string); + if let Some(p) = &path { + // Skipped font files are logged; the document opens without them. + let _ = crate::cmd::load_document_fonts(p); + } let mut st = DocState::new(d, path); // Unsaved: the copy on disk (if any) is older than what was recovered. st.saved_doc = std::sync::Arc::new((*st.doc).clone()); diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index c5f0271..7b53235 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -43,6 +43,45 @@ enum FontBytes { Owned(Arc>), } +/// Where a face's font came from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FontSource { + /// Compiled into DesignCraft. + Bundled, + /// Font data handed to [`FontDb::add_font`], with no file behind it. + Memory, + /// An installed font file (the system's or the user's font folders). + Installed(std::path::PathBuf), + /// A font file in a document's `Document Fonts` folder ([`FontDb::load_document_fonts`]). + Document(std::path::PathBuf), +} + +impl FontSource { + /// The font file, if the face was loaded from one. + pub fn path(&self) -> Option<&std::path::Path> { + match self { + FontSource::Installed(p) | FontSource::Document(p) => Some(p), + FontSource::Bundled | FontSource::Memory => None, + } + } +} + +/// The folder beside a document whose fonts the document brings with it (as File › Package +/// writes it). +pub const DOCUMENT_FONTS_FOLDER: &str = "Document Fonts"; +/// Caps on what a `Document Fonts` folder (untrusted files) can make [`FontDb::load_document_fonts`] +/// read: font files read, bytes per file and bytes in all. +pub const MAX_DOCUMENT_FONT_FILES: usize = 200; +pub const MAX_DOCUMENT_FONT_BYTES: u64 = 256 << 20; +pub const MAX_DOCUMENT_FONTS_TOTAL: u64 = 512 << 20; + +/// What [`FontDb::load_document_fonts`] did: faces added, and a note for each file it skipped. +#[derive(Debug, Default)] +pub struct DocumentFonts { + pub faces: usize, + pub skipped: Vec, +} + /// One loaded font face. pub struct FontFace { id: u32, @@ -53,6 +92,8 @@ pub struct FontFace { /// usWeightClass-style weight (400 = regular). pub weight: f32, pub italic: bool, + /// Where the font came from (a file to copy when packaging, a document's own font). + pub source: FontSource, bytes: FontBytes, index: u32, pub upem: f64, @@ -213,6 +254,15 @@ impl FontFace { .and_then(|gdef| gdef.glyph_class_def()?.ok()) .is_some_and(|classes| classes.get(GlyphId::new(gid)) == 3) } + + /// Does the font's licence forbid passing it on? OS/2 `fsType` Restricted License embedding + /// (bit 1) without a less restrictive bit (Preview & Print, Editable). Fonts without an OS/2 + /// table aren't restricted. + pub fn restricted_licence(&self) -> bool { + use skrifa::raw::TableProvider; + let fs_type = self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type()); + fs_type & 0x0002 != 0 && fs_type & 0x000C == 0 + } } #[cfg(not(target_arch = "wasm32"))] @@ -492,9 +542,11 @@ type Found = (u32, String, String, Vec<([u8; 4], f32)>); /// Parse every face in `data` (a font file or collection); a variable font yields one face per /// named instance (InDesign lists them as styles). fn enumerate_faces(data: &[u8]) -> Vec { + /// A collection's count is input: what a damaged one can make the loop try. + const MAX_FACES: u32 = 1024; let count = match FileRef::new(data) { Ok(FileRef::Font(_)) => 1, - Ok(FileRef::Collection(c)) => c.len(), + Ok(FileRef::Collection(c)) => c.len().min(MAX_FACES), Err(_) => 0, }; let mut out = Vec::new(); @@ -527,14 +579,14 @@ pub(crate) fn last_resort_face() -> Arc { // The font is compiled in (`include_bytes!`), so parsing it can't depend on input; the // `last_resort_face_parses` test proves it on every run. #[allow(clippy::expect_used)] - let face = make_face(FontBytes::Static(BUNDLED[0]), 0, FALLBACK_FAMILY.into(), "Regular".into(), Vec::new()) + let face = make_face(FontBytes::Static(BUNDLED[0]), FontSource::Bundled, 0, FALLBACK_FAMILY.into(), "Regular".into(), Vec::new()) .expect("the compiled-in Source Sans 3 Regular parses"); Arc::new(face) }) .clone() } -fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option { +fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option { let data: &[u8] = match &bytes { FontBytes::Static(b) => b, FontBytes::Owned(v) => v.as_slice(), @@ -569,6 +621,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords coords, location, instance, + source, bytes, index, bmp: std::sync::OnceLock::new(), @@ -619,7 +672,7 @@ impl FontDb { let craft = crate::japanese_document_fonts().into_iter().map(|f| f.bytes); for data in BUNDLED.iter().copied().chain(craft) { for (i, family, style, coords) in enumerate_faces(data) { - if let Some(f) = make_face(FontBytes::Static(data), i, family, style, coords) { + if let Some(f) = make_face(FontBytes::Static(data), FontSource::Bundled, i, family, style, coords) { faces.push(Arc::new(f)); } } @@ -699,13 +752,30 @@ impl FontDb { /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or /// every face was already present). pub fn add_font(&self, bytes: Vec) -> usize { + self.add_faces(bytes, FontSource::Memory) + } + + /// Add the faces of a font file from `source`. A document's font is skipped only when the same + /// folder already gave that family and style (it stands in for other fonts of the name); any + /// other is skipped when a face of that family and style is loaded. + fn add_faces(&self, bytes: Vec, source: FontSource) -> usize { let data = Arc::new(bytes); + let folder = |s: &FontSource| match s { + FontSource::Document(p) => p.parent().map(std::path::Path::to_path_buf), + _ => None, + }; + let doc_folder = folder(&source); let mut added = 0; for (i, family, style, coords) in enumerate_faces(&data) { - if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) { + let same = |f: &&Arc| { + f.family.eq_ignore_ascii_case(&family) + && f.style.eq_ignore_ascii_case(&style) + && (doc_folder.is_none() || folder(&f.source) == doc_folder) + }; + if self.read_faces().iter().any(|f| same(&f)) { continue; } - if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, coords) { + if let Some(f) = make_face(FontBytes::Owned(data.clone()), source.clone(), i, family, style, coords) { self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f)); added += 1; } @@ -789,12 +859,72 @@ impl FontDb { let mut any = false; for p in paths { if let Ok(data) = std::fs::read(&p) { - any |= self.add_font(data) > 0; + any |= self.add_faces(data, FontSource::Installed(p)) > 0; } } any } + /// Load the font files (.ttf, .otf, .ttc, .otc; not in subfolders) of a document's `Document + /// Fonts` folder. Their faces come before other faces of the same family and style when a + /// family is looked up, and stay loaded. The files are untrusted: past + /// [`MAX_DOCUMENT_FONT_FILES`] (in name order), files over [`MAX_DOCUMENT_FONT_BYTES`] or past + /// [`MAX_DOCUMENT_FONTS_TOTAL`] in all, and files that aren't fonts are skipped with a note. A + /// missing folder loads nothing. + #[cfg(not(target_arch = "wasm32"))] + pub fn load_document_fonts(&self, folder: &std::path::Path) -> DocumentFonts { + use std::io::Read; + let mut out = DocumentFonts::default(); + let Ok(entries) = std::fs::read_dir(folder) else { return out }; + let mut files: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) && p.is_file() + }) + .collect(); + files.sort(); + let mut total = 0u64; + for (n, path) in files.iter().enumerate() { + if n == MAX_DOCUMENT_FONT_FILES { + out.skipped.push(format!("{} more font files not loaded (at most {MAX_DOCUMENT_FONT_FILES} are)", files.len() - n)); + break; + } + let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()); + let mut data = Vec::new(); + let too_large = format!("larger than {} MB", MAX_DOCUMENT_FONT_BYTES >> 20); + let over_total = format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20); + // The listed length skips oversized files unread; one byte past the limit while reading + // catches a file that grew since. + let listed = std::fs::metadata(path).map_or(0, |m| m.len()); + let read = if listed > MAX_DOCUMENT_FONT_BYTES || total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL { + Ok(0) + } else { + std::fs::File::open(path).and_then(|f| f.take(MAX_DOCUMENT_FONT_BYTES + 1).read_to_end(&mut data)) + }; + let reason = match read { + Err(e) => Some(format!("can't be read ({e})")), + Ok(_) if listed > MAX_DOCUMENT_FONT_BYTES => Some(too_large), + Ok(len) if len as u64 > MAX_DOCUMENT_FONT_BYTES => Some(too_large), + Ok(len) if total.saturating_add(listed.max(len as u64)) > MAX_DOCUMENT_FONTS_TOTAL => Some(over_total), + Ok(_) if enumerate_faces(&data).is_empty() => Some("not a font DesignCraft can read".into()), + Ok(len) => { + total = total.saturating_add(len as u64); + None + } + }; + match reason { + Some(r) => out.skipped.push(format!("{name}: {r}")), + None => out.faces += self.add_faces(data, FontSource::Document(path.clone())), + } + } + for s in &out.skipped { + log::warn!("{}: {s}", folder.display()); + } + out + } + /// Resolve a family + style to a face, falling back to the closest style of the family, then to /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. pub fn face(&self, family: &str, style: &str) -> Arc { @@ -864,7 +994,7 @@ impl FontDb { None => coords.push((tag, v)), } } - let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?; + let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?; let f = Arc::new(f); self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone()); Some(f) @@ -881,7 +1011,12 @@ impl FontDb { return self.instance(family, style); } let faces = self.read_faces(); - let cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + // Document fonts first, the most recently loaded first: they stand in for other fonts of + // the same name. + let (mut cands, others): (Vec<&Arc>, Vec<&Arc>) = + faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).partition(|f| matches!(f.source, FontSource::Document(_))); + cands.reverse(); + cands.extend(others); if cands.is_empty() { return None; } @@ -977,7 +1112,7 @@ impl FontDb { let Ok(data) = std::fs::read(&p) else { continue }; let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())); - if hit && self.add_font(data) > 0 && covered(self) { + if hit && self.add_faces(data, FontSource::Installed(p)) > 0 && covered(self) { return true; } } @@ -1036,3 +1171,8 @@ mod tests_sysfonts; #[cfg(not(target_arch = "wasm32"))] #[path = "tests_fallback.rs"] mod tests_fallback; + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +#[path = "tests_docfonts.rs"] +mod tests_docfonts; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 9b3f0fb..0f25bec 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -11,7 +11,10 @@ mod fontdb; #[cfg(any(test, feature = "testing"))] pub mod testing; -pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, base_style, bundled, system_font_dirs}; +pub use fontdb::{ + DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, + MAX_DOCUMENT_FONTS_TOTAL, base_style, bundled, system_font_dirs, +}; pub use harfrust::Feature; use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer}; pub use kurbo::BezPath; diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs index c1ccae8..1c576ed 100644 --- a/crates/fonts/src/testing.rs +++ b/crates/fonts/src/testing.rs @@ -82,3 +82,13 @@ pub fn font_with(family: &str, chars: &[char]) -> Option> { } Some(out) } + +/// `font` with its OS/2 `fsType` (embedding licence bits) set to `fs_type`. `None` if the font has +/// no OS/2 table. +pub fn with_fs_type(mut font: Vec, fs_type: u16) -> Option> { + let tables = usize::from(u16::from_be_bytes(font.get(4..6)?.try_into().ok()?)); + let rec = (0..tables).map(|r| 12 + r * 16).find(|rec| font.get(*rec..rec + 4) == Some(b"OS/2"))?; + let offset = u32::from_be_bytes(font.get(rec + 8..rec + 12)?.try_into().ok()?) as usize; + font.get_mut(offset + 8..offset + 10)?.copy_from_slice(&fs_type.to_be_bytes()); + Some(font) +} diff --git a/crates/fonts/src/tests_docfonts.rs b/crates/fonts/src/tests_docfonts.rs new file mode 100644 index 0000000..e1fb019 --- /dev/null +++ b/crates/fonts/src/tests_docfonts.rs @@ -0,0 +1,119 @@ +//! Document fonts: the fonts in a `Document Fonts` folder beside a document, loaded ahead of the +//! installed fonts of the same name; damaged, oversized and surplus files are skipped. + +use std::path::{Path, PathBuf}; + +use super::*; +use crate::testing::{font_with, with_fs_type}; + +/// An empty temporary folder unique to `name` and this process. +fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dc-docfonts-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn write_font(dir: &Path, file: &str, family: &str, chars: &[char]) -> PathBuf { + let path = dir.join(file); + std::fs::write(&path, font_with(family, chars).unwrap()).unwrap(); + path +} + +#[test] +fn document_fonts_load_ahead_of_installed_ones() { + let root = temp_dir("precedence"); + let system = root.join("system"); + let folder = root.join(DOCUMENT_FONTS_FOLDER); + std::fs::create_dir_all(&system).unwrap(); + std::fs::create_dir_all(&folder).unwrap(); + write_font(&system, "installed.ttf", "DocFont Precedence", &['a']); + let file = write_font(&folder, "doc.ttf", "DocFont Precedence", &['b']); + + let db = FontDb::with_font_dirs(vec![system]); + // The installed face is loaded first (the document was composed before its folder appeared). + let installed = db.face("DocFont Precedence", "Regular"); + assert!(installed.covers('a') && matches!(installed.source, FontSource::Installed(_)), "{:?}", installed.source); + + let r = db.load_document_fonts(&folder); + assert_eq!((r.faces, r.skipped.len()), (1, 0), "{:?}", r.skipped); + let face = db.face("DocFont Precedence", "Regular"); + assert!(face.covers('b'), "the document's face wins"); + assert_eq!(face.source, FontSource::Document(file.clone())); + assert_eq!(face.source.path(), Some(file.as_path())); + + // Loading the folder again adds nothing. + let again = db.load_document_fonts(&folder); + assert_eq!((again.faces, again.skipped.len()), (0, 0)); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn damaged_oversized_and_other_files_are_skipped() { + let folder = temp_dir("skipped"); + write_font(&folder, "good.otf", "DocFont Good", &['g']); + std::fs::write(folder.join("broken.ttf"), b"not a font at all").unwrap(); + // Sparse: takes no disk space, but its length is over the limit. + std::fs::File::create(folder.join("huge.ttc")).unwrap().set_len(MAX_DOCUMENT_FONT_BYTES + 1).unwrap(); + std::fs::write(folder.join("readme.txt"), b"fonts for the brochure").unwrap(); + std::fs::create_dir_all(folder.join("nested.ttf")).unwrap(); + write_font(&folder.join("nested.ttf"), "inner.ttf", "DocFont Nested", &['n']); + + let db = FontDb::with_font_dirs(vec![]); + let r = db.load_document_fonts(&folder); + assert_eq!(r.faces, 1); + assert!(db.has_family("DocFont Good")); + assert!(!db.has_family("DocFont Nested"), "subfolders aren't read"); + assert_eq!(r.skipped.len(), 2, "{:?}", r.skipped); + assert!(r.skipped.iter().any(|s| s.contains("broken.ttf")), "{:?}", r.skipped); + assert!(r.skipped.iter().any(|s| s.contains("huge.ttc") && s.contains(&format!("{} MB", MAX_DOCUMENT_FONT_BYTES >> 20))), "{:?}", r.skipped); + let _ = std::fs::remove_dir_all(&folder); +} + +#[test] +fn the_number_of_files_read_is_capped() { + let folder = temp_dir("cap"); + for i in 0..MAX_DOCUMENT_FONT_FILES + 5 { + std::fs::write(folder.join(format!("{i:04}.ttf")), b"junk").unwrap(); + } + // Sorts after every junk file, so it is past the cap. + write_font(&folder, "zz-real.ttf", "DocFont Past Cap", &['z']); + + let db = FontDb::with_font_dirs(vec![]); + let r = db.load_document_fonts(&folder); + assert_eq!(r.faces, 0); + assert!(!db.has_family("DocFont Past Cap")); + assert_eq!(r.skipped.len(), MAX_DOCUMENT_FONT_FILES + 1, "one note for each file read, one for the rest"); + assert!(r.skipped.last().is_some_and(|s| s.contains("6 more") && s.contains("200")), "{:?}", r.skipped.last()); + let _ = std::fs::remove_dir_all(&folder); +} + +#[test] +fn large_cjk_collections_are_within_the_limit() { + // macOS's Songti collection is 67 MB; full Noto/Source Han CJK collections are larger. + let folder = temp_dir("cjk-size"); + std::fs::File::create(folder.join("cjk.ttc")).unwrap().set_len(200 << 20).unwrap(); + let r = FontDb::with_font_dirs(vec![]).load_document_fonts(&folder); + assert_eq!(r.skipped.len(), 1, "{:?}", r.skipped); + assert!(!r.skipped[0].contains("larger than"), "skipped for not being a font, not for its size: {:?}", r.skipped); + let _ = std::fs::remove_dir_all(&folder); +} + +#[test] +fn a_missing_folder_loads_nothing() { + let db = FontDb::with_font_dirs(vec![]); + let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER)); + assert_eq!((r.faces, r.skipped.len()), (0, 0)); +} + +#[test] +fn restricted_licence_reads_the_embedding_bits() { + let db = FontDb::with_font_dirs(vec![]); + for (fs_type, restricted) in [(0x0000, false), (0x0002, true), (0x0006, false), (0x000A, false), (0x0008, false), (0x0302, true)] { + let family = format!("DocFont Licence {fs_type:04x}"); + assert_eq!(db.add_font(with_fs_type(font_with(&family, &['l']).unwrap(), fs_type).unwrap()), 1); + assert_eq!(db.face(&family, "Regular").restricted_licence(), restricted, "fsType {fs_type:#06x}"); + } + assert!(!db.face(FALLBACK_FAMILY, "Regular").restricted_licence(), "the bundled fonts are OFL"); + assert_eq!(db.face(FALLBACK_FAMILY, "Regular").source, FontSource::Bundled); +} diff --git a/docs/cjk.md b/docs/cjk.md index 483c67c..4db80ef 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -41,7 +41,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Ruby | group ruby only, fixed 50 % size, no options, doesn't affect leading | | Kenten | the IDML kinds and a custom character, drawn as characters; no position, size, alignment, colour or font | | Composite fonts | done: model, `style.compositeFont.*`, shaping, IDML, font list, replace and Preflight; no dialog | -| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; fallback by language (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); no per-document fonts | +| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; fallback by language (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); document fonts: a `Document Fonts` folder beside an opened file loads ahead of installed fonts, and Package copies the fonts used there (licence permitting). They stay loaded for the session (shared by every open document, not unloaded on close) and aren't read on the web | | Everything else below | missing | Known bugs to fix first: From e0fb78a23062fd93956c2349aade326887213156 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 09:05:29 -0400 Subject: [PATCH 05/17] CJK 1.4: Korean breaks at spaces Korean is set word by word (KLREQ): Hangul, in any language, and Han ideographs in Korean-language text take no line breaks between characters, so lines break at spaces. Glyphs carry their language tag for this. A paragraph attribute, koreanCharBreaks (type.para, paragraph styles, Paragraph Style Options > Justification), breaks between syllables instead. It is DesignCraft-only; IDML doesn't carry it. A paragraph's kinsoku set decides which breaks it forbids, and Korean still breaks at spaces under one. Kinsoku still holds, also when the single-line composer has to split a word wider than the measure: the split moves back so no line starts with closing punctuation. --- ROADMAP.md | 2 +- crates/compose/src/breaker.rs | 51 ++++++++++++++++--- crates/compose/src/lib.rs | 4 +- crates/compose/src/shape.rs | 4 ++ crates/compose/src/tests.rs | 96 +++++++++++++++++++++++++++++++++++ crates/doc/src/attrs.rs | 3 ++ crates/engine/src/cmd/text.rs | 19 +++++++ crates/ui-egui/src/dialogs.rs | 9 ++++ crates/ui-egui/src/i18n.rs | 11 ++++ crates/ui-egui/src/i18n/ar.rs | 2 + docs/cjk.md | 2 +- 11 files changed, 193 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1fd114e..3a62ae4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. - Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture. - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. -- Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text). Punctuation and brackets take their vertical forms (OpenType `vert`/`vrt2`) in vertical frames. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Vertical metrics (`vmtx`), warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. +- Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Punctuation and brackets take their vertical forms (OpenType `vert`/`vrt2`) in vertical frames. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Vertical metrics (`vmtx`), warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. - Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 555aa68..7ce7823 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -48,6 +48,8 @@ pub struct Spacing { pub hyph_zone: f64, /// Optical margin alignment: punctuation hangs outside the measure (counted as extra width). pub optical: bool, + /// Korean text breaks between syllables instead of at spaces. + pub korean_char_breaks: bool, } impl Default for Spacing { @@ -68,6 +70,7 @@ impl Default for Spacing { ragged_stretch: 24.0, hyph_zone: 0.0, optical: false, + korean_char_breaks: false, } } } @@ -168,15 +171,41 @@ const INF: f64 = 10000.0; /// hangul), except where kinsoku forbids it: no line starts with closing punctuation, small /// kana or the prolonged sound mark, and none ends with opening brackets. pub fn cjk_break_between(a: char, b: char) -> bool { - let cjk = |c: char| crate::upright_in_vertical(c) || matches!(c as u32, 0x3000..=0x303F); - if !(cjk(a) || cjk(b)) { - return false; - } + (is_cjk(a) || is_cjk(b)) && kinsoku_allows(a, b) +} + +fn is_cjk(c: char) -> bool { + crate::upright_in_vertical(c) || matches!(c as u32, 0x3000..=0x303F) +} + +fn kinsoku_allows(a: char, b: char) -> bool { const NO_START: &str = "、。,.・:;?!ー)」』】〕〉》}]〙〗ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ々〻ゝゞヽヾ!),.:;?]}"; const NO_END: &str = "(「『【〔〈《{[〘〖([{"; !NO_START.contains(b) && !NO_END.contains(a) } +/// Hangul, and Han ideographs (hanja) in Korean text: Korean is set word by word (KLREQ), so +/// these letters break like Western ones, at spaces. +fn korean_letter(g: &Glyph) -> bool { + let c = g.ch as u32; + let hangul = matches!(c, 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF); + let han = matches!(c, 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x3FFFF); + hangul || (han && g.lang == Some("ko")) +} + +/// Is the place between glyphs `a` and `b` one where CJK text may break (next to an ideograph, kana +/// or hangul)? Korean letters take no breaks between characters unless the paragraph asks for +/// character-based breaking. +pub(crate) fn cjk_pair(a: &Glyph, b: &Glyph, korean_char_breaks: bool) -> bool { + let cjk = |g: &Glyph| is_cjk(g.ch) && (korean_char_breaks || !korean_letter(g)); + cjk(a) || cjk(b) +} + +/// [`cjk_break_between`] for glyphs `a` and `b` (see [`cjk_pair`]). +fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool { + cjk_pair(a, b, sp.korean_char_breaks) && kinsoku_allows(a.ch, b.ch) +} + pub fn is_forced(c: char) -> bool { use designcraft_doc::story::*; matches!(c, FORCED_LINE_BREAK | COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK) @@ -310,7 +339,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec let mut p = Item::penalty(0.0, 50.0, true); p.hang = right_hang(g, sp); push_inword(&mut it, &mut ig, p, i + 1, sp); - } else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break_between(g.ch, glyphs[i + 1].ch)) { + } else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp)) { let mut p = Item::penalty(0.0, 0.0, false); p.hang = right_hang(g, sp); push_inword(&mut it, &mut ig, p, i + 1, sp); @@ -590,6 +619,14 @@ fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain: out } +/// Where a line with no break opportunity before glyph `i` (a word wider than the measure) is +/// split: before `i`, or earlier where kinsoku forbids that (no line starts with closing +/// punctuation). +fn emergency_split(glyphs: &[Glyph], start: usize, i: usize) -> usize { + let allowed = |j: usize| matches!((glyphs.get(j.wrapping_sub(1)), glyphs.get(j)), (Some(a), Some(b)) if kinsoku_allows(a.ch, b.ch)); + (start + 1..=i).rev().find(|&j| allowed(j)).unwrap_or(i) +} + /// Greedy first-fit breaking (single-line composer; also used for paragraphs with tabs). pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec { let n = glyphs.len(); @@ -638,7 +675,7 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F (last_space.map_or(i, |s| s.0), false, false) } Some((p, h)) => (p, h, false), - None => (i, false, false), + None => (emergency_split(glyphs, start, i), false, false), }); break; } @@ -648,7 +685,7 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F } if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break && g.break_after != Some(false) { if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') - || g.break_after.unwrap_or_else(|| cjk_break_between(g.ch, glyphs[i + 1].ch)) + || g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp)) { last_ok = Some((i + 1, false)); } else if hyph_after[i] && may_hyphenate(hyphens) { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 10f293c..1b46c70 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1128,6 +1128,7 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { ragged_stretch: base_size * 2.0, hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone }, optical: pp.optical_margin, + korean_char_breaks: pp.korean_char_breaks, } } @@ -1146,7 +1147,8 @@ fn apply_desired_spacing(glyphs: &mut [Glyph], pp: &ParaProps) { if let Some(next) = right.first() { let a = g.ch; let b = next.ch; - let cjk = upright_in_vertical(a) || upright_in_vertical(b); + // A kinsoku set rules where CJK text may break; Korean still breaks at spaces. + let cjk = breaker::cjk_pair(g, next, pp.korean_char_breaks); if g.break_after != Some(false) { if let Some(set) = &pp.kinsoku { if !set.allows(a, b) { diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 92e297f..a3d0cfe 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -59,6 +59,8 @@ pub struct Glyph { pub allow_kashidas: bool, pub safe_tatweel_before: bool, pub shaping_rtl: bool, + /// The character's language as a BCP 47 tag ([`designcraft_doc::language_tag`]). + pub lang: Option<&'static str>, } impl Glyph { @@ -752,6 +754,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty allow_kashidas: p.allow_kashidas, safe_tatweel_before: false, shaping_rtl: false, + lang: designcraft_doc::language_tag(&p.language), } } @@ -851,6 +854,7 @@ fn shape_segment( allow_kashidas: p.allow_kashidas, safe_tatweel_before: sg.safe_tatweel_before, shaping_rtl: sg.rtl, + lang, }); } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a1261a3..c88cfa8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1493,3 +1493,99 @@ fn cjk_fallback_follows_the_language() { let (primary, han) = faces_of("English: USA"); assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone())); } + +const KO_WORDS: &str = "한국어 문장은 띄어쓰기 단위로 줄을 바꿉니다 한국어 문장은 띄어쓰기 단위로 줄을 바꿉니다"; +const KO_HANJA: &str = "大韓民國 憲法은 國民의 權利를 保障한다 大韓民國 憲法은 國民의 權利를 保障한다"; +const KO_PUNCT: &str = "가나다라마。바사아자차」카타파하가、나다라마바。사아자차카」타파하가나、다라마바사。아자차카타」파하가나다、"; +/// Syllables for words wider than the frame. +const KO_LONG: &str = "가나다라마바사아자차카타파하」"; + +/// A Korean paragraph in a narrow frame (70 pt), set in a stand-in font covering the samples. +fn korean_doc(text: &str, language: &str, para: ParaAttrs) -> (Document, StoryId) { + let chars: Vec = [KO_WORDS, KO_HANJA, KO_PUNCT, KO_LONG].concat().chars().collect(); + let font = designcraft_fonts::testing::font_with("DC Test Korean", &chars).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 106.0, 400.0), para); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| { + f.over.font_family = Some("DC Test Korean".into()); + f.over.language = Some(language.into()); + }); + (d, sid) +} + +/// The lines (after the first) that start inside a space-separated word. +fn lines_starting_mid_word(d: &Document, sid: StoryId, text: &str) -> Vec { + let cs = compose_story(d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 2, "wraps: {} lines", lines.len()); + lines.iter().skip(1).filter(|l| !text[..l.range.start].ends_with(' ')).map(|l| text[l.range.clone()].to_string()).collect() +} + +#[test] +fn korean_breaks_at_spaces() { + for composer in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] { + for align in [Align::Left, Align::LeftJustified] { + let para = ParaAttrs { composer: Some(composer), align: Some(align), ..Default::default() }; + // By script: Hangul is set word by word whatever the language. + for language in ["Korean", "English: USA"] { + let (d, sid) = korean_doc(KO_WORDS, language, para.clone()); + let mid = lines_starting_mid_word(&d, sid, KO_WORDS); + assert!(mid.is_empty(), "{composer:?} {align:?} {language}: {mid:?}"); + } + } + } +} + +#[test] +fn korean_breaks_at_spaces_under_a_kinsoku_set() { + let set = designcraft_doc::cjk::Kinsoku::named("KoreanKinsoku"); + assert!(set.is_some()); + for composer in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] { + let para = ParaAttrs { composer: Some(composer), kinsoku: Some(set.clone()), ..Default::default() }; + let (d, sid) = korean_doc(KO_WORDS, "Korean", para.clone()); + let mid = lines_starting_mid_word(&d, sid, KO_WORDS); + assert!(mid.is_empty(), "{composer:?}: {mid:?}"); + let (d, sid) = korean_doc(KO_WORDS, "Korean", ParaAttrs { korean_char_breaks: Some(true), ..para }); + assert!(!lines_starting_mid_word(&d, sid, KO_WORDS).is_empty(), "{composer:?}: breaks between syllables on request"); + } +} + +#[test] +fn korean_character_breaks_on_request() { + let para = ParaAttrs { korean_char_breaks: Some(true), composer: Some(designcraft_doc::Composer::SingleLine), ..Default::default() }; + let (d, sid) = korean_doc(KO_WORDS, "Korean", para); + assert!(!lines_starting_mid_word(&d, sid, KO_WORDS).is_empty(), "breaks between syllables"); +} + +#[test] +fn hanja_in_korean_text_breaks_at_spaces() { + let para = ParaAttrs { composer: Some(designcraft_doc::Composer::SingleLine), ..Default::default() }; + let (d, sid) = korean_doc(KO_HANJA, "Korean", para.clone()); + let mid = lines_starting_mid_word(&d, sid, KO_HANJA); + assert!(mid.is_empty(), "{mid:?}"); + // Han in Japanese text breaks between characters. + let (d, sid) = korean_doc(KO_HANJA, "Japanese", para); + assert!(!lines_starting_mid_word(&d, sid, KO_HANJA).is_empty()); +} + +#[test] +fn korean_lines_keep_kinsoku() { + // Words of 9 to 14 syllables and a closing bracket: in one of them the bracket is what + // overflows the frame. + let syllables: Vec = KO_LONG.chars().collect(); + let long = (9..=14).map(|n| format!("{}」 가나", syllables[..n].iter().collect::())); + for text in std::iter::once(KO_PUNCT.to_string()).chain(long) { + let text = text.as_str(); + for char_breaks in [false, true] { + for composer in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] { + let para = ParaAttrs { korean_char_breaks: Some(char_breaks), composer: Some(composer), ..Default::default() }; + let (d, sid) = korean_doc(text, "Korean", para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 1, "wraps"); + let texts: Vec<&str> = lines.iter().map(|l| &text[l.range.clone()]).collect(); + assert!(texts.iter().all(|t| !t.starts_with(['、', '。', '」'])), "kinsoku ({char_breaks}, {composer:?}): {texts:?}"); + } + } + } +} diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index c56752a..ff21dd7 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -699,6 +699,9 @@ attr_set! { /// Preserved vendor policy; non-default policies require a dedicated composer. arabic_justification: String = String::new(), paragraph_kashida_width: Option = None, + /// Korean text breaks between any two syllables (character-based breaking); otherwise + /// it breaks at spaces, word by word. + korean_char_breaks: bool = false, // Keeps keep_with_next: u32 = 0, keep_lines_together: bool = false, diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index c1b10b9..6a68435 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1377,3 +1377,22 @@ mod arabic_tests { assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::LeftToRight); } } + +#[cfg(test)] +mod korean_breaks_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn korean_character_breaks_are_a_paragraph_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "한국어 문장"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("type.para", &json!({"attrs": {"koreanCharBreaks": true}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.korean_char_breaks, Some(true)); + s.execute("style.paragraph.create", &json!({"name": "Korean", "para": {"koreanCharBreaks": true}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para("Korean").unwrap().para.korean_char_breaks, Some(true)); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bfa83d4..222f1f2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2088,6 +2088,15 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo d.fields.insert("p.kashidas".into(), json!(k)); } ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Korean Line Breaks:")); + let mut kb = cur(d, "p.koreanCharBreaks", &pv["koreanCharBreaks"]).as_bool().unwrap_or(false); + if ui + .checkbox(&mut kb, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Between syllables (not only at spaces)"))) + .changed() + { + d.fields.insert("p.koreanCharBreaks".into(), json!(kb)); + } + ui.end_row(); }); } "color" => { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 60fae75..fdf8afe 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1916,6 +1916,17 @@ const TABLE: &[(&str, [&str; 5])] = &[ "在两端对齐的阿拉伯语文本中", ], ), + ("Korean Line Breaks:", ["Korean Line Breaks:", "Korean Line Breaks:", "Korean Line Breaks:", "Korean Line Breaks:", "韩文换行:"]), + ( + "Between syllables (not only at spaces)", + [ + "Between syllables (not only at spaces)", + "Between syllables (not only at spaces)", + "Between syllables (not only at spaces)", + "Between syllables (not only at spaces)", + "在音节之间(不仅在空格处)", + ], + ), ("Binding:", ["Binding:", "Binding:", "Binding:", "Binding:", "装订方向:"]), ("Ruby:", ["Ruby:", "Ruby:", "Ruby:", "Ruby:", "注音:"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 03f97a6..fabd36d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -335,6 +335,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Glyph Scaling:", "تحجيم الحروف:"), ("Insert Kashidas:", "إدراج الكشيدة:"), ("In justified Arabic text", "في النص العربي المضبوط"), + ("Korean Line Breaks:", "فواصل الأسطر الكورية:"), + ("Between syllables (not only at spaces)", "بين المقاطع (وليس عند المسافات فقط)"), ("Minimum Space Before First Footnote:", "أقل مسافة قبل الحاشية الأولى:"), ("Space Between Footnotes:", "المسافة بين الحواشي:"), ("Offset:", "الإزاحة:"), diff --git a/docs/cjk.md b/docs/cjk.md index 4db80ef..50ceb5a 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -34,6 +34,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Vertical frames, upright CJK, rotated Latin | done (per frame; glyphs turned by transforms) | | `vert`/`vrt2` in vertical frames | done (applied to every run, Latin included); Horizontal Kana (`hkna`) becomes `vkna` | | Kinsoku | named (hard, soft, Chinese, Korean) and custom kinsoku sets, bunri-kinshi, rensuuji, hanging punctuation (regular, force); without a set, fixed no-start / no-end lists and a break anywhere between CJK characters; kinsoku type (push in / push out priorities) kept but not applied | +| Korean line breaking | at spaces (Hangul everywhere, hanja in Korean text); per-paragraph character-based breaking (`koreanCharBreaks`); DesignCraft-only, not in IDML | | Mojikumi | tables and paragraph references kept through IDML; not applied (Preflight says so) | | Aki, tsume, jidori | done ([cjk-typography.md](cjk-typography.md)) | | Character alignment, leading model | em box top / centre / bottom, ICF from ascender and descender, roman baseline; aki above / below, centre (centre down as centre) | @@ -46,7 +47,6 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built Known bugs to fix first: -- Korean breaks between every syllable (it should break at spaces by default). - Vertical advances are the horizontal ones and upright glyphs turn about a guessed centre (`adv × 0.38`), so proportional and non-square glyphs sit wrong in vertical text. - A justified CJK line has no stretch except Single Word Justification. From 1602b37ec9407e4f34472481faefd6bdce5ffe85 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 14:37:44 -0400 Subject: [PATCH 06/17] CJK 1.5: fonts read vertical metrics FontFace::v_advance, v_origin and em_box read vmtx/vhea, VORG (else the glyph top plus its top side bearing) and the ideographic em box (BASE ideo/idtp, else OS/2 typo metrics, else ascender/descender, centred on one em); fonts without them fall back to the em box. shape_vertical shapes top to bottom with those metrics (vert, and vkrn/vpal when asked). --- crates/fonts/src/fontdb.rs | 23 ++++ crates/fonts/src/lib.rs | 75 ++++++++++- crates/fonts/src/testing.rs | 142 +++++++++++++++++---- crates/fonts/src/tests_vertical.rs | 192 +++++++++++++++++++++++++++++ crates/fonts/src/vertical.rs | 127 +++++++++++++++++++ 5 files changed, 537 insertions(+), 22 deletions(-) create mode 100644 crates/fonts/src/tests_vertical.rs create mode 100644 crates/fonts/src/vertical.rs diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 7b53235..0ba6531 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -113,6 +113,8 @@ pub struct FontFace { pub instance: Option, /// Basic Multilingual Plane coverage bitset, built on first use. bmp: std::sync::OnceLock>, + /// The ideographic em box, read on first use. + em: std::sync::OnceLock<(f64, f64)>, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -241,6 +243,22 @@ impl FontFace { .map(|a| a as f64) .unwrap_or(self.upem * 0.5) } + /// Vertical advance (down the line) of glyph `gid` in font units: `vmtx`, else one em. + pub fn v_advance(&self, gid: u32) -> f64 { + crate::vertical::VMetrics::new(self).advance(GlyphId::new(gid)) + } + /// Height of glyph `gid`'s vertical origin above its baseline in font units: `VORG`, else its + /// top plus its `vmtx` top side bearing, else the em box top. Across, the origin is at half the + /// horizontal advance. Upright glyphs in vertical text hang from it on the line's centre. + pub fn v_origin(&self, gid: u32) -> f64 { + crate::vertical::VMetrics::new(self).origin(GlyphId::new(gid)) + } + /// The ideographic em box (top, bottom) in font units, y up (Japanese fonts: typically 880, + /// -120): `BASE` `idtp`/`ideo`, else the OS/2 typo ascender/descender, else the ascender and + /// descender, the last two centred on one em. + pub fn em_box(&self) -> (f64, f64) { + *self.em.get_or_init(|| crate::vertical::em_box(self)) + } /// Glyph id for `c` (0 = .notdef). pub fn glyph_for(&self, c: char) -> u32 { self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) @@ -625,6 +643,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s bytes, index, bmp: std::sync::OnceLock::new(), + em: std::sync::OnceLock::new(), }) } @@ -1176,3 +1195,7 @@ mod tests_fallback; #[cfg(not(target_arch = "wasm32"))] #[path = "tests_docfonts.rs"] mod tests_docfonts; + +#[cfg(test)] +#[path = "tests_vertical.rs"] +mod tests_vertical; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 0f25bec..bbce046 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -10,6 +10,7 @@ mod fontdb; #[cfg(any(test, feature = "testing"))] pub mod testing; +mod vertical; pub use fontdb::{ DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, @@ -57,13 +58,19 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> { /// InDesign's default text font is a serif; ours is Source Serif 4. pub const DEFAULT_FAMILY: &str = "Source Serif 4"; -/// One shaped glyph, in font units. +/// One shaped glyph, in font units (y up). +/// +/// Horizontal runs ([`shape`]) advance by `x_advance` (`y_advance` is 0) and the offsets move the +/// glyph from its pen position. Vertical runs ([`shape_vertical`]) advance down the line by +/// `-y_advance` (`x_advance` is 0) and the offsets move the glyph from its vertical origin +/// ([`FontFace::v_origin`]) on the line's centre, as `vpal` or `vkrn` place it. #[derive(Clone, Copy, Debug, PartialEq)] pub struct ShapedGlyph { pub gid: u32, /// Byte offset (in the shaped string) of the cluster this glyph belongs to. pub cluster: usize, pub x_advance: i32, + pub y_advance: i32, pub x_offset: i32, pub y_offset: i32, pub safe_tatweel_before: bool, @@ -205,6 +212,7 @@ fn shape_dir( gid: info.glyph_id, cluster: info.cluster as usize, x_advance: pos.x_advance, + y_advance: 0, x_offset: pos.x_offset, y_offset: pos.y_offset, safe_tatweel_before: info.safe_to_insert_tatweel(), @@ -224,6 +232,7 @@ fn shape_dir( gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, + y_advance: 0, x_offset: 0, y_offset: 0, safe_tatweel_before: false, @@ -234,6 +243,70 @@ fn shape_dir( out } +/// Shape upright text top to bottom (`text`, `features`, `map` and `context` as in +/// [`shape_with_context`]): the shaper applies `vert` (vertical forms), and `vkrn` and `vpal` when +/// `features` turn them on. Advances and origins are the face's vertical metrics +/// ([`FontFace::v_advance`], [`FontFace::v_origin`]). +pub fn shape_vertical(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, context: ShapeContext<'_>) -> Vec { + let mut funcs = vertical::VerticalFuncs { face, metrics: vertical::VMetrics::new(face) }; + let mut out = Vec::with_capacity(text.len()); + let mut origins = Vec::with_capacity(text.len()); + let shaped = face.hb().map(|hb| { + let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); + let mut buf = UnicodeBuffer::new(); + for (i, c) in text.char_indices() { + buf.add(map(c), i as u32); + } + buf.set_pre_context(context.before); + buf.set_post_context(context.after); + if let Some(language) = context.language.and_then(|t| t.parse::().ok()) { + buf.set_language(language); + } + buf.guess_segment_properties(); + buf.set_direction(Direction::TopToBottom); + let gb = shaper.shape(buf, ShapeOptions::new().features(features).font_funcs(Some(&mut funcs))); + for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { + out.push(ShapedGlyph { + gid: info.glyph_id, + cluster: info.cluster as usize, + x_advance: pos.x_advance, + y_advance: pos.y_advance, + x_offset: pos.x_offset, + y_offset: pos.y_offset, + safe_tatweel_before: false, + rtl: false, + }); + } + }); + if shaped.is_none() + && let Some(f) = face.skrifa() + { + let cmap = f.charmap(); + for (i, c) in text.char_indices() { + let gid = cmap.map(map(c)).unwrap_or_default().to_u32(); + let (x, y) = funcs.origin(gid); + let adv = -(face.v_advance(gid).round() as i32); + out.push(ShapedGlyph { + gid, + cluster: i, + x_advance: 0, + y_advance: adv, + x_offset: -x, + y_offset: -y, + safe_tatweel_before: false, + rtl: false, + }); + } + } + // The shaper hangs each glyph from its vertical origin; keep only what GPOS moved it by. + origins.extend(out.iter().map(|g| funcs.origin(g.gid))); + for (g, (x, y)) in out.iter_mut().zip(origins) { + g.x_offset = g.x_offset.saturating_add(x); + g.y_offset = g.y_offset.saturating_add(y); + } + out +} + /// Glyph id of the first of `chars` the face has (0 = .notdef). pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0) diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs index 1c576ed..3c6e437 100644 --- a/crates/fonts/src/testing.rs +++ b/crates/fonts/src/testing.rs @@ -7,20 +7,21 @@ use skrifa::MetadataProvider; /// isn't installed (a system CJK font on a machine without it). `None` only if the bundled font /// can't be read. pub fn font_with(family: &str, chars: &[char]) -> Option> { - let base = *crate::bundled().first()?; - let gid = skrifa::FontRef::new(base).ok()?.charmap().map('X')?.to_u32(); - let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes); - let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + let glyphs: Vec<(char, char)> = chars.iter().map(|c| (*c, 'X')).collect(); + font_mapping(family, &glyphs) +} - let mut tables: Vec<([u8; 4], Vec)> = Vec::new(); - for r in 0..usize::from(be16(base, 4)?) { - let rec = 12 + r * 16; - let tag: [u8; 4] = base.get(rec..rec + 4)?.try_into().ok()?; - let (offset, len) = (be32(base, rec + 8)? as usize, be32(base, rec + 12)? as usize); - if &tag != b"name" && &tag != b"cmap" { - tables.push((tag, base.get(offset..offset.checked_add(len)?)?.to_vec())); - } - } +/// [`font_with`], mapping each character of `glyphs` to Source Sans 3's glyph for the character +/// paired with it (`('一', 'X')` draws 一 as X), so characters can have glyphs (and metrics) of +/// their own. `None` if the bundled font can't be read or lacks a glyph asked for. +pub fn font_mapping(family: &str, glyphs: &[(char, char)]) -> Option> { + let base = *crate::bundled().first()?; + let base_cmap = skrifa::FontRef::new(base).ok()?.charmap(); + let mut map: Vec<(u32, u32)> = glyphs.iter().map(|(c, g)| Some((*c as u32, base_cmap.map(*g)?.to_u32()))).collect::>()?; + map.sort_unstable(); + map.dedup_by_key(|m| m.0); + let mut tables = tables_of(base)?; + tables.retain(|(tag, _)| tag != b"name" && tag != b"cmap"); // `name`: format 0, family (1) and subfamily (2), Windows Unicode English. let strings: Vec> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect(); @@ -39,10 +40,7 @@ pub fn font_with(family: &str, chars: &[char]) -> Option> { tables.push((*b"name", name)); // `cmap`: one format 12 subtable (Windows, full Unicode), one group per character. - let mut cps: Vec = chars.iter().map(|c| *c as u32).collect(); - cps.sort_unstable(); - cps.dedup(); - let sub_len = u32::try_from(16 + 12 * cps.len()).ok()?; + let sub_len = u32::try_from(16 + 12 * map.len()).ok()?; let mut cmap = Vec::new(); for v in [0u16, 1, 3, 10] { cmap.extend_from_slice(&v.to_be_bytes()); @@ -50,21 +48,39 @@ pub fn font_with(family: &str, chars: &[char]) -> Option> { cmap.extend_from_slice(&12u32.to_be_bytes()); cmap.extend_from_slice(&12u16.to_be_bytes()); cmap.extend_from_slice(&0u16.to_be_bytes()); - for v in [sub_len, 0, u32::try_from(cps.len()).ok()?] { + for v in [sub_len, 0, u32::try_from(map.len()).ok()?] { cmap.extend_from_slice(&v.to_be_bytes()); } - for cp in cps { + for (cp, gid) in map { for v in [cp, cp, gid] { cmap.extend_from_slice(&v.to_be_bytes()); } } tables.push((*b"cmap", cmap)); + assemble(base, tables) +} - // The file: table directory (sorted by tag), then the tables, each 4-byte aligned. +/// Every table of the font file `font`: (tag, bytes). +fn tables_of(font: &[u8]) -> Option)>> { + let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes); + let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + let mut tables = Vec::new(); + for r in 0..usize::from(be16(font, 4)?) { + let rec = 12 + r * 16; + let tag: [u8; 4] = font.get(rec..rec + 4)?.try_into().ok()?; + let (offset, len) = (be32(font, rec + 8)? as usize, be32(font, rec + 12)? as usize); + tables.push((tag, font.get(offset..offset.checked_add(len)?)?.to_vec())); + } + Some(tables) +} + +/// A font file of `tables`, with the sfnt version of `font`: the table directory (sorted by tag), +/// then the tables, each 4-byte aligned. +fn assemble(font: &[u8], mut tables: Vec<([u8; 4], Vec)>) -> Option> { tables.sort_by_key(|t| t.0); let n = u16::try_from(tables.len()).ok()?; let pow = 1u16 << (15 - n.leading_zeros().min(15)); - let mut out = base.get(0..4)?.to_vec(); + let mut out = font.get(0..4)?.to_vec(); for v in [n, pow * 16, pow.trailing_zeros() as u16, n * 16 - pow * 16] { out.extend_from_slice(&v.to_be_bytes()); } @@ -83,6 +99,62 @@ pub fn font_with(family: &str, chars: &[char]) -> Option> { Some(out) } +/// `font` with `tables` added, each replacing the table of its tag. `None` if `font` can't be read. +fn with_tables(font: &[u8], tables: Vec<([u8; 4], Vec)>) -> Option> { + let mut all = tables_of(font)?; + all.retain(|(tag, _)| !tables.iter().any(|(t, _)| t == tag)); + all.extend(tables); + assemble(font, all) +} + +/// `font` with vertical metrics: a `vhea` table and a `vmtx` table that give every glyph `default` +/// (advance down the line, top side bearing), except the glyphs `overrides` lists (glyph id, +/// advance, top side bearing). Every glyph has a long metric. `None` if `font` can't be read. +pub fn with_vmtx(font: &[u8], default: (u16, i16), overrides: &[(u16, u16, i16)]) -> Option> { + use skrifa::raw::TableProvider; + let glyphs = skrifa::FontRef::new(font).ok()?.maxp().ok()?.num_glyphs(); + let mut vmtx = Vec::with_capacity(usize::from(glyphs) * 4); + let mut max_advance = 0; + for gid in 0..glyphs { + let (advance, tsb) = overrides.iter().find(|o| o.0 == gid).map_or(default, |o| (o.1, o.2)); + max_advance = max_advance.max(advance); + vmtx.extend_from_slice(&advance.to_be_bytes()); + vmtx.extend_from_slice(&tsb.to_be_bytes()); + } + // `vhea` 1.1: typo ascender, descender and line gap, advanceHeightMax, minTop/BottomSideBearing, + // yMaxExtent, caret slope rise and run, caret offset, four reserved, metricDataFormat and + // numOfLongVerMetrics (at byte 34). + let mut vhea = 0x0001_1000_u32.to_be_bytes().to_vec(); + for v in [500i16, -500, 0] { + vhea.extend_from_slice(&v.to_be_bytes()); + } + vhea.extend_from_slice(&max_advance.to_be_bytes()); + for v in [0i16, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0] { + vhea.extend_from_slice(&v.to_be_bytes()); + } + vhea.extend_from_slice(&glyphs.to_be_bytes()); + with_tables(font, vec![(*b"vhea", vhea), (*b"vmtx", vmtx)]) +} + +/// `font` with a `VORG` table: every glyph's vertical origin is `default` above its baseline, +/// except the glyphs `overrides` lists (glyph id, origin). `None` if `font` can't be read. +pub fn with_vorg(font: &[u8], default: i16, overrides: &[(u16, i16)]) -> Option> { + let mut entries = overrides.to_vec(); + entries.sort_unstable_by_key(|e| e.0); + entries.dedup_by_key(|e| e.0); + let mut vorg = Vec::new(); + for v in [1u16, 0] { + vorg.extend_from_slice(&v.to_be_bytes()); + } + vorg.extend_from_slice(&default.to_be_bytes()); + vorg.extend_from_slice(&u16::try_from(entries.len()).ok()?.to_be_bytes()); + for (gid, y) in entries { + vorg.extend_from_slice(&gid.to_be_bytes()); + vorg.extend_from_slice(&y.to_be_bytes()); + } + with_tables(font, vec![(*b"VORG", vorg)]) +} + /// `font` with its OS/2 `fsType` (embedding licence bits) set to `fs_type`. `None` if the font has /// no OS/2 table. pub fn with_fs_type(mut font: Vec, fs_type: u16) -> Option> { @@ -92,3 +164,31 @@ pub fn with_fs_type(mut font: Vec, fs_type: u16) -> Option> { font.get_mut(offset + 8..offset + 10)?.copy_from_slice(&fs_type.to_be_bytes()); Some(font) } + +/// Offset and length of table `tag` in `font`'s table directory, and where its record is. +fn table(font: &[u8], tag: &[u8; 4]) -> Option<(usize, usize, usize)> { + let tables = usize::from(u16::from_be_bytes(font.get(4..6)?.try_into().ok()?)); + let rec = (0..tables).map(|r| 12 + r * 16).find(|rec| font.get(*rec..rec + 4) == Some(tag))?; + let offset = u32::from_be_bytes(font.get(rec + 8..rec + 12)?.try_into().ok()?) as usize; + let len = u32::from_be_bytes(font.get(rec + 12..rec + 16)?.try_into().ok()?) as usize; + Some((rec, offset, len)) +} + +/// `font` with table `tag` cut to `len` bytes (its directory length; the bytes stay). `None` if +/// the font has no such table. +pub fn with_table_len(mut font: Vec, tag: &[u8; 4], len: u32) -> Option> { + let (rec, _, _) = table(&font, tag)?; + font.get_mut(rec + 12..rec + 16)?.copy_from_slice(&len.to_be_bytes()); + Some(font) +} + +/// `font` with the big-endian `u16` at byte `at` of table `tag` set to `value`. `None` if the font +/// has no such table or it is too short. +pub fn with_table_u16(mut font: Vec, tag: &[u8; 4], at: usize, value: u16) -> Option> { + let (_, offset, len) = table(&font, tag)?; + if at + 2 > len { + return None; + } + font.get_mut(offset + at..offset + at + 2)?.copy_from_slice(&value.to_be_bytes()); + Some(font) +} diff --git a/crates/fonts/src/tests_vertical.rs b/crates/fonts/src/tests_vertical.rs new file mode 100644 index 0000000..729ba3b --- /dev/null +++ b/crates/fonts/src/tests_vertical.rs @@ -0,0 +1,192 @@ +//! Vertical metrics (`vmtx`, `vhea`, `VORG`), the ideographic em box and top-to-bottom shaping. +//! +//! The synthetic font (Source Sans 3 with `vhea`/`vmtx`, and `VORG` where asked) runs everywhere; +//! the checks against Shippori Mincho's own tables run when built with craft-fonts. + +use skrifa::raw::TableProvider; + +use super::*; +use crate::testing::{font_mapping, font_with, with_table_len, with_table_u16, with_vmtx, with_vorg}; +use crate::{ShapeContext, feature, shape_vertical, shape_with_context}; + +/// Text in Japanese. +fn japanese() -> ShapeContext<'static> { + ShapeContext { language: Some("ja"), ..Default::default() } +} + +/// The synthetic font's characters and the Source Sans 3 glyphs that draw them. +const GLYPHS: &[(char, char)] = &[('一', 'X'), ('ヸ', 'O'), ('、', ',')]; + +/// A font with vertical metrics: every glyph one em down the line with a top side bearing of 120, +/// except ヸ (drawn as O): 1.024 em, top side bearing 100. +fn vertical_font() -> Vec { + let base = font_mapping("Vertical", GLYPHS).unwrap(); + let wi = gid_in(&base, 'ヸ'); + with_vmtx(&base, (1000, 120), &[(wi, 1024, 100)]).unwrap() +} + +fn gid_in(font: &[u8], c: char) -> u16 { + let gid = skrifa::FontRef::new(font).unwrap().charmap().map(c).unwrap().to_u32(); + u16::try_from(gid).unwrap() +} + +/// Shippori Mincho from craft-fonts, or `None` (with a note) when built without it. +fn shippori() -> Option> { + if !crate::japanese_fonts().any(|f| f.family == "Shippori Mincho") { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)"); + return None; + } + let face = FontDb::global().face("Shippori Mincho", "Regular"); + assert_eq!(face.family, "Shippori Mincho"); + Some(face) +} + +fn face_of(bytes: Vec) -> FontFace { + make_face(FontBytes::Owned(Arc::new(bytes)), FontSource::Memory, 0, "Test".into(), "Regular".into(), Vec::new()).unwrap() +} + +#[test] +fn reads_vmtx() { + let face = face_of(vertical_font()); + let font = face.skrifa().unwrap(); + let gm = font.glyph_metrics(Size::unscaled(), LocationRef::default()); + for (c, advance, tsb) in [('一', 1000.0, 120.0), ('、', 1000.0, 120.0), ('ヸ', 1024.0, 100.0)] { + let gid = face.glyph_for(c); + assert_ne!(gid, 0, "{c}"); + assert_eq!(face.v_advance(gid), advance, "{c}"); + // No VORG (TrueType outlines): the origin is the glyph's top plus its top side bearing. + let top = f64::from(gm.bounds(GlyphId::new(gid)).unwrap().y_max); + assert_eq!(face.v_origin(gid), top + tsb, "{c}"); + } + // The em box doesn't come from vmtx: Source Sans 3's BASE table. + assert_eq!(face.em_box(), (830.0, -170.0)); + // Glyphs past the long metrics advance as the last of them. + let face = face_of(with_table_u16(vertical_font(), b"vhea", 34, 1).unwrap()); + assert_eq!(face.v_advance(face.glyph_for('ヸ')), 1000.0); +} + +#[test] +fn vorg_gives_the_vertical_origin() { + let wi = gid_in(&vertical_font(), 'ヸ'); + let face = face_of(with_vorg(&vertical_font(), 880, &[(wi, 900)]).unwrap()); + assert_eq!(face.v_origin(face.glyph_for('一')), 880.0); + assert_eq!(face.v_origin(u32::from(wi)), 900.0); + assert_eq!(face.v_advance(u32::from(wi)), 1024.0, "advances still come from vmtx"); + // VORG without vmtx: origins from VORG, one em down the line. + let base = font_mapping("Vorg Only", GLYPHS).unwrap(); + let face = face_of(with_vorg(&base, 870, &[]).unwrap()); + let ichi = face.glyph_for('一'); + assert_eq!((face.v_advance(ichi), face.v_origin(ichi)), (1000.0, 870.0)); +} + +#[test] +fn shippori_reads_vmtx() { + let Some(face) = shippori() else { return }; + let font = face.skrifa().unwrap(); + let vmtx = font.vmtx().unwrap(); + let gm = font.glyph_metrics(Size::unscaled(), LocationRef::default()); + for c in ['「', '、', '一', 'ヸ'] { + let gid = face.glyph_for(c); + let g = GlyphId::new(gid); + assert_eq!(face.v_advance(gid), f64::from(vmtx.advance(g).unwrap()), "{c}"); + // No VORG (TrueType outlines): the origin is the glyph's top plus its top side bearing. + let top = f64::from(gm.bounds(g).unwrap().y_max) + f64::from(vmtx.side_bearing(g).unwrap()); + assert_eq!(face.v_origin(gid), top, "{c}"); + } + // No BASE table: the em box is the OS/2 typo ascender and descender (exactly one em here). + assert_eq!(face.em_box(), (880.0, -120.0)); + // Ideographs fill the em box; ヸ is taller than it is wide. + let ichi = face.glyph_for('一'); + assert_eq!((face.v_advance(ichi), face.v_origin(ichi)), (1000.0, 880.0)); + let wi = face.glyph_for('ヸ'); + assert_eq!((face.advance(wi), face.v_advance(wi)), (1000.0, 1024.0)); +} + +#[test] +fn fonts_without_vmtx_use_the_em_box() { + // Source Sans 3 has no vmtx; its BASE table gives the ideographic em box bottom (-170). + let face = face_of(font_with("No Vmtx", &['一']).unwrap()); + let gid = face.glyph_for('一'); + assert_ne!(gid, 0); + assert_eq!(face.em_box(), (830.0, -170.0)); + assert_eq!(face.v_advance(gid), face.upem); + assert_eq!(face.v_origin(gid), 830.0); + // Without BASE either: the OS/2 typo ascender and descender (1000, -326), centred on one em. + let face = face_of(with_table_len(font_with("No Base", &['一']).unwrap(), b"BASE", 0).unwrap()); + assert_eq!(face.em_box(), (837.0, -163.0)); + // Without OS/2: the ascender and descender, the same way. + let face = face_of(with_table_len(with_table_len(font_with("No OS2", &['一']).unwrap(), b"BASE", 0).unwrap(), b"OS/2", 0).unwrap()); + let (top, bottom) = face.em_box(); + assert_eq!(top - bottom, face.upem); + assert!((top + bottom - (face.ascent - face.descent)).abs() < 1e-9, "{top} {bottom}"); +} + +#[test] +fn broken_vertical_tables_do_not_panic() { + let mut fonts = vec![("synthetic", vertical_font())]; + if let Some(face) = shippori() { + fonts.push(("Shippori Mincho", face.data().to_vec())); + } + let vorg = with_vorg(&vertical_font(), 880, &[(1, 900), (2, 910)]).unwrap(); + let mut cases = vec![ + ("synthetic: VORG cut short".to_string(), with_table_len(vorg.clone(), b"VORG", 6).unwrap()), + ("synthetic: more origins than VORG holds".to_string(), with_table_u16(vorg, b"VORG", 6, u16::MAX).unwrap()), + ("BASE cut short".to_string(), with_table_len(crate::bundled()[0].to_vec(), b"BASE", 6).unwrap()), + ]; + for (name, font) in fonts { + cases.extend([ + (format!("{name}: vmtx cut short"), with_table_len(font.clone(), b"vmtx", 2).unwrap()), + (format!("{name}: vmtx empty"), with_table_len(font.clone(), b"vmtx", 0).unwrap()), + (format!("{name}: more metrics than vmtx holds"), with_table_u16(font.clone(), b"vhea", 34, u16::MAX).unwrap()), + (format!("{name}: no long metrics"), with_table_u16(font.clone(), b"vhea", 34, 0).unwrap()), + (format!("{name}: vhea cut short"), with_table_len(font, b"vhea", 10).unwrap()), + ]); + } + for (what, bytes) in cases { + let face = face_of(bytes); + let (top, bottom) = face.em_box(); + assert!(top > bottom && (top - bottom - face.upem).abs() < 1e-9, "{what}: {top} {bottom}"); + for gid in [0, face.glyph_for('一'), face.glyph_for('、'), u32::from(u16::MAX), u32::MAX] { + let (adv, origin) = (face.v_advance(gid), face.v_origin(gid)); + assert!(adv.is_finite() && adv >= 0.0 && origin.is_finite(), "{what}: {gid} {adv} {origin}"); + } + let g = shape_vertical(&face, "一、ま", &[feature("vpal").unwrap()], |c| c, japanese()); + assert_eq!(g.len(), 3, "{what}"); + } +} + +#[test] +fn upright_text_shapes_top_to_bottom() { + let face = face_of(vertical_font()); + let h = shape_with_context(&face, "一ヸ、", &[], |c| c, japanese()); + let v = shape_vertical(&face, "一ヸ、", &[], |c| c, japanese()); + // This font has no vertical forms (`vert`): the same glyphs. + assert_eq!(v.iter().map(|g| g.gid).collect::>(), h.iter().map(|g| g.gid).collect::>()); + // Advances run down the line, by vmtx; offsets are what GPOS moves a glyph from its vertical + // origin (nothing here). + let placed: Vec<_> = v.iter().map(|g| (g.x_advance, g.y_advance, g.x_offset, g.y_offset)).collect(); + assert_eq!(placed, [(0, -1000, 0, 0), (0, -1024, 0, 0), (0, -1000, 0, 0)]); + // Origins from VORG leave the offsets alone too. + let face = face_of(with_vorg(&vertical_font(), 880, &[(gid_in(&vertical_font(), 'ヸ'), 900)]).unwrap()); + let v = shape_vertical(&face, "一ヸ", &[], |c| c, ShapeContext::default()); + assert_eq!(v.iter().map(|g| (g.y_advance, g.x_offset, g.y_offset)).collect::>(), [(-1000, 0, 0), (-1024, 0, 0)]); +} + +#[test] +fn shippori_takes_vertical_forms_and_proportional_metrics() { + let Some(face) = shippori() else { return }; + let h = shape_with_context(&face, "一、", &[], |c| c, japanese()); + let v = shape_vertical(&face, "一、", &[], |c| c, japanese()); + assert_eq!(v[0].gid, h[0].gid); + // `vert` without asking: the comma's vertical form. + assert_ne!(v[1].gid, h[1].gid); + assert_eq!(face.v_origin(v[1].gid), 880.0); + // Advances run down the line; offsets are what GPOS moves a glyph from its vertical origin. + for g in &v { + assert_eq!((g.x_advance, g.y_advance, g.x_offset, g.y_offset), (0, -1000, 0, 0), "{g:?}"); + } + assert_eq!(shape_vertical(&face, "ヸ", &[], |c| c, ShapeContext::default())[0].y_advance, -1024); + // `vpal` (proportional vertical metrics) shortens kana and moves them along the line. + let ma = shape_vertical(&face, "ま", &[feature("vpal").unwrap()], |c| c, ShapeContext::default())[0]; + assert_eq!((ma.y_advance, ma.x_offset, ma.y_offset), (-974, 0, 4)); +} diff --git a/crates/fonts/src/vertical.rs b/crates/fonts/src/vertical.rs new file mode 100644 index 0000000..d360b6f --- /dev/null +++ b/crates/fonts/src/vertical.rs @@ -0,0 +1,127 @@ +//! Vertical metrics for upright glyphs in vertical text: advances down the line (`vmtx`), vertical +//! origins (`VORG`, else `vmtx` and the glyph's top) and the ideographic em box (`BASE`, else +//! OS/2). Fonts without them fall back to the em box: one em down the line, the origin at its top. + +use skrifa::instance::Size; +use skrifa::metrics::GlyphMetrics; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::{vmtx::Vmtx, vorg::Vorg}; +use skrifa::raw::types::Tag; +use skrifa::{GlyphId, MetadataProvider}; + +use crate::FontFace; + +/// A face's vertical metrics tables, read once for a run of lookups. +pub(crate) struct VMetrics<'a> { + vmtx: Option>, + vorg: Option>, + glyphs: Option>, + upem: f64, + em_top: f64, +} + +impl<'a> VMetrics<'a> { + pub(crate) fn new(face: &'a FontFace) -> Self { + let font = face.skrifa(); + VMetrics { + vmtx: font.as_ref().and_then(|f| f.vmtx().ok()), + vorg: font.as_ref().and_then(|f| f.vorg().ok()), + glyphs: font.map(|f| f.glyph_metrics(Size::unscaled(), face.location())), + upem: face.upem, + em_top: face.em_box().0, + } + } + + /// Advance down the line in font units. + pub(crate) fn advance(&self, gid: GlyphId) -> f64 { + self.vmtx.as_ref().and_then(|t| t.advance(gid)).map_or(self.upem, f64::from) + } + + /// Height of the vertical origin above the glyph's baseline in font units. + pub(crate) fn origin(&self, gid: GlyphId) -> f64 { + if let Some(t) = &self.vorg { + return f64::from(t.vertical_origin_y(gid)); + } + // TrueType outlines: the glyph's top plus its top side bearing. + if let (Some(t), Some(g)) = (&self.vmtx, &self.glyphs) + && let (Some(tsb), Some(b)) = (t.side_bearing(gid), g.bounds(gid)) + && t.advance(gid).is_some() + { + return f64::from(b.y_max) + f64::from(tsb); + } + self.em_top + } +} + +/// The ideographic em box (top, bottom) in font units, y up: `BASE` `idtp`/`ideo`, else the OS/2 +/// typo ascender and descender, else the ascender and descender; the last two centred on one em. +pub(crate) fn em_box(face: &FontFace) -> (f64, f64) { + let upem = face.upem; + let font = face.skrifa(); + if let Some(b) = font.as_ref().and_then(|f| base_em_box(f, upem)) { + return b; + } + let centred = |asc: f64, desc: f64| { + let span = asc - desc; + (span > 0.0 && span.is_finite()).then(|| { + let top = asc - (span - upem) / 2.0; + (top, top - upem) + }) + }; + font.and_then(|f| f.os2().ok()) + .and_then(|t| centred(f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender()))) + .or_else(|| centred(face.ascent, -face.descent)) + .unwrap_or((upem * 0.88, upem * -0.12)) +} + +/// The em box from the `BASE` table's horizontal axis: the Han script's values, else the default +/// script's, else the first script's that has them. +fn base_em_box(font: &skrifa::FontRef<'_>, upem: f64) -> Option<(f64, f64)> { + let axis = font.base().ok()?.horiz_axis()?.ok()?; + let tags = axis.base_tag_list()?.ok()?; + let index = |tag: &[u8; 4]| tags.baseline_tags().iter().position(|t| t.get() == Tag::new(tag)); + let (ideo, idtp) = (index(b"ideo"), index(b"idtp")); + if ideo.is_none() && idtp.is_none() { + return None; + } + let scripts = axis.base_script_list().ok()?; + let records = scripts.base_script_records(); + let rank = |t: Tag| [Tag::new(b"hani"), Tag::new(b"kana"), Tag::new(b"DFLT")].iter().position(|p| *p == t).unwrap_or(3); + let mut order: Vec<_> = records.iter().collect(); + order.sort_by_key(|r| rank(r.base_script_tag())); + order.into_iter().find_map(|r| { + let values = r.base_script(scripts.offset_data()).ok()?.base_values()?.ok()?; + let coord = |i: Option| i.and_then(|i| values.base_coords().get(i).ok()).map(|c| f64::from(c.coordinate())); + match (coord(ideo), coord(idtp)) { + (Some(bottom), Some(top)) => (top > bottom).then_some((top, bottom)), + (Some(bottom), None) => Some((bottom + upem, bottom)), + (None, Some(top)) => Some((top, top - upem)), + (None, None) => None, + } + }) +} + +/// The shaper's vertical metrics callbacks: ours, so that its vertical advances and origins are +/// the ones glyphs are placed with. +pub(crate) struct VerticalFuncs<'a> { + pub(crate) face: &'a FontFace, + pub(crate) metrics: VMetrics<'a>, +} + +impl VerticalFuncs<'_> { + /// The vertical origin (x, y) in whole font units: centred across, [`VMetrics::origin`] up. + pub(crate) fn origin(&self, gid: u32) -> (i32, i32) { + ((self.face.advance(gid) / 2.0).round() as i32, self.metrics.origin(GlyphId::new(gid)).round() as i32) + } +} + +impl harfrust::font::FontFuncs for VerticalFuncs<'_> { + fn advance_height(&mut self, _: &harfrust::font::BuiltinFontFuncs, glyph: harfrust::GlyphId) -> i32 { + // The shaper's y axis points up: advancing down the line is negative. + -(self.metrics.advance(GlyphId::new(glyph.to_u32())).round() as i32) + } + + fn vertical_origin(&mut self, _: &harfrust::font::BuiltinFontFuncs, glyph: harfrust::GlyphId) -> (i32, i32) { + self.origin(glyph.to_u32()) + } +} From 1574dcdd45825effec21d77aae1cd4834e61c825 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 09:07:44 -0400 Subject: [PATCH 07/17] CJK 1.6: upright text is shaped top to bottom In vertical frames upright runs are shaped TopToBottom (vert; vkrn/vpal when asked), advance by their vertical metrics and hang from their vertical origins on the em box centre, on screen and in PDF; their line ascent and descent are the em box. Turned runs stay horizontal without vrt2 (rather than every run taking vert and vrt2), and tate-chu-yoko stays horizontal and centres on the em box, its offsets moving it along and across the line. Horizontal Kana in upright runs takes vkna. Each run is shaped with the text around it, as horizontal runs are. --- ROADMAP.md | 4 +- crates/compose/src/lib.rs | 24 +++-- crates/compose/src/shape.rs | 105 ++++++++++++++++------ crates/compose/src/tests.rs | 169 ++++++++++++++++++++++++++++++++++++ docs/cjk.md | 4 +- 5 files changed, 269 insertions(+), 37 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3a62ae4..6f60cf4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. - Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture. - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. -- Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Punctuation and brackets take their vertical forms (OpenType `vert`/`vrt2`) in vertical frames. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Vertical metrics (`vmtx`), warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. +- Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. - Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). @@ -185,7 +185,7 @@ four agents in parallel on separate crates: |---|---| | Open P0 (2: PDF/X-4 validation against a certified checker, PostScript interpretation for EPS) | 8 h | | Open P1 (none partial) | 0 h | -| Open P2 (4 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback, vertical metrics and warichu) | 14 h | +| Open P2 (4 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback, warichu) | 14 h | | Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h | | Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 55 h | diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 1b46c70..43eb9e1 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -115,15 +115,29 @@ pub struct PlacedGlyph { } impl PlacedGlyph { - /// In a vertical frame, the turn that sets this glyph upright (about its em box centre, or - /// across the line in a tate-chu-yoko group), applied after drawing it at `x` on `baseline`. + /// In a vertical frame, the turn that sets this glyph upright, applied after drawing it at `x` + /// on `baseline`: it hangs from its vertical origin ([`designcraft_fonts::FontFace::v_origin`], + /// centred across) at `x` on the line's centre, the middle of the font's em box; a tate-chu-yoko + /// group sits across that centre. pub fn vertical_xf(&self, baseline: f64) -> Option { let turn = -std::f64::consts::FRAC_PI_2; + // The em box centre above the baseline, in ems. + let (top, bottom) = self.face.em_box(); + let centre = (top + bottom) / 2.0 / self.face.units_per_em(); if let Some([along, across, em]) = self.tcy { - let c = Point::new(self.x + along, baseline + self.y - em * 0.38); + let c = Point::new(self.x + along, baseline + self.y - em * centre); return Some(designcraft_geom::Affine::rotate_about(turn, c) * designcraft_geom::Affine::translate((c.x + across - self.x, 0.0))); } - self.upright.then(|| designcraft_geom::Affine::rotate_about(turn, Point::new(self.x + self.adv / 2.0, baseline + self.y - self.adv * 0.38))) + if !self.upright { + return None; + } + // Turning about c takes the glyph's vertical origin (half across, `origin` up) to the + // centre point at `x`: c is where the two points' perpendicular bisector meets the turn. + let half = self.face.advance(self.gid) / 2.0 * self.sx; + let origin = self.face.v_origin(self.gid) * self.sy; + let mid = centre * self.face.units_per_em() * self.sy; + let c = Point::new(self.x + (origin - mid + half) / 2.0, baseline + self.y + (half - mid - origin) / 2.0); + Some(designcraft_geom::Affine::rotate_about(turn, c)) } } @@ -1960,7 +1974,7 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph { byte: g.byte, len: g.len, visible, - upright: upright_in_vertical(g.ch), + upright: g.upright, tcy: g.tcy, rtl: false, } diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index a3d0cfe..8bd1bc9 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -61,6 +61,9 @@ pub struct Glyph { pub shaping_rtl: bool, /// The character's language as a BCP 47 tag ([`designcraft_doc::language_tag`]). pub lang: Option<&'static str>, + /// Upright in a vertical frame: shaped top to bottom (`adv` is its vertical advance) and hung + /// from its vertical origin on the line's centre (see [`crate::PlacedGlyph::vertical_xf`]). + pub upright: bool, } impl Glyph { @@ -99,7 +102,8 @@ pub struct SubstCtx { pub objects: HashMap, /// Conditions currently hidden (conditional text with only these isn't shown). pub hidden_conditions: Vec, - /// Set in a Vertical Type frame: vertical glyph forms (`vert`, `vrt2`). + /// Set in a Vertical Type frame: upright runs are shaped top to bottom (vertical forms and + /// metrics), the rest horizontally to be turned as a whole. pub vertical: bool, } @@ -619,10 +623,11 @@ fn shape_run_raw( } type WordMap = HashMap, Arc<[ShapedGlyph]>>; -type WordKey = (u32, bool, String, Option<&'static str>); +type WordKey = (u32, bool, bool, String, Option<&'static str>); -/// Shaped words (font units) by (face, caps, features, language), then text. Lookups take read -/// locks and misses are shaped outside any lock, so parallel composition doesn't serialize here. +/// Shaped words (font units) by (face, vertical, caps, features, language), then text. Lookups +/// take read locks and misses are shaped outside any lock, so parallel composition doesn't +/// serialize here. static WORD_CACHE: RwLock>)>> = RwLock::new(Vec::new()); const WORD_CACHE_MAX: usize = 50_000; @@ -645,13 +650,28 @@ fn word_map(key: WordKey) -> Arc> { /// Shape `src` word by word through a cache (like a browser's word cache): the text is split /// after each U+0020 so repeated words are shaped once. Shaping does not cross word spaces in the /// scripts we lay out, and clusters stay byte offsets into `src`. -fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], lang: Option<&'static str>, caps: bool, around: (&str, &str)) -> Vec { +fn shape_cached( + face: &FontFace, + src: &str, + feats: &[Feature], + lang: Option<&'static str>, + caps: bool, + vertical: bool, + (before, after): (&str, &str), +) -> Vec { + use designcraft_fonts::ShapeContext; let map = |c: char| if caps { c.to_uppercase().next().unwrap_or(c) } else { c }; + let shape = |s: &str, context: ShapeContext<'_>| { + if vertical { + designcraft_fonts::shape_vertical(face, s, feats, map, context) + } else { + designcraft_fonts::shape_with_context(face, s, feats, map, context) + } + }; if src.len() < 2 || !src.contains(' ') || src.chars().any(designcraft_fonts::is_rtl) { - let (before, after) = around; - return designcraft_fonts::shape_with_context(face, src, feats, map, designcraft_fonts::ShapeContext { before, after, language: lang }); + return shape(src, ShapeContext { before, after, language: lang }); } - let words = word_map((face.id(), caps, format!("{feats:?}"), lang)); + let words = word_map((face.id(), vertical, caps, format!("{feats:?}"), lang)); let pieces: Vec<&str> = src.split_inclusive(' ').collect(); let mut found: Vec>> = { let r = words.read().unwrap_or_else(|e| e.into_inner()); @@ -660,14 +680,7 @@ fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], lang: Option<&'st let mut fresh: Vec<(Box, Arc<[ShapedGlyph]>)> = Vec::new(); for (k, f) in found.iter_mut().enumerate() { if f.is_none() { - let g: Arc<[ShapedGlyph]> = designcraft_fonts::shape_with_context( - face, - pieces[k], - feats, - map, - designcraft_fonts::ShapeContext { language: lang, ..Default::default() }, - ) - .into(); + let g: Arc<[ShapedGlyph]> = shape(pieces[k], ShapeContext { language: lang, ..Default::default() }).into(); fresh.push((pieces[k].into(), g.clone())); *f = Some(g); } @@ -755,6 +768,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty safe_tatweel_before: false, shaping_rtl: false, lang: designcraft_doc::language_tag(&p.language), + upright: false, } } @@ -778,23 +792,37 @@ fn shape_segment( let manual = if let Kerning::Manual(v) = p.kerning { v / 1000.0 * p.size } else { 0.0 }; let src = replacement.unwrap_or(&text[range.clone()]); let caps = p.capitalization == Capitalization::AllCaps; - let mut feats = features_for(p); + let feats = features_for(p); + // Upright in vertical text, Horizontal Kana takes the vertical kana alternates instead. + let mut upright_feats = feats.clone(); if vertical && designcraft_doc::otf::is_on(&p.otf_features, "hkna") { - feats.extend(feature("-hkna")); - feats.extend(feature("vkna")); - } - if vertical { - feats.extend(["vert", "vrt2"].iter().filter_map(|t| designcraft_fonts::feature(t))); + upright_feats.extend(feature("-hkna")); + upright_feats.extend(feature("vkna")); } let space = face.advance(face.glyph_for(' ')) * k * hs; // The language picks the font's localized forms (`locl`); the text around the run lets // letters join across a change of style. let lang = designcraft_doc::language_tag(&p.language); - let around = if replacement.is_none() { (text.get(..range.start).unwrap_or(""), text.get(range.end..).unwrap_or("")) } else { ("", "") }; - let shaped: Vec = shape_cached(face, src, &feats, lang, caps, around); + // Vertical frames: upright runs top to bottom, the rest (turned as a whole) horizontally. + // Tate-chu-yoko is set across the line, so it stays horizontal. + let mut shaped: Vec = Vec::new(); + let mut upright: Vec = Vec::new(); + for (r, up) in upright_runs(src, vertical && !p.tate_chu_yoko) { + let around = match replacement { + None => (text.get(..range.start + r.start).unwrap_or(""), text.get(range.start + r.end..).unwrap_or("")), + Some(_) => ("", ""), + }; + let g = shape_cached(face, &src[r.clone()], if up { &upright_feats } else { &feats }, lang, caps, up, around); + upright.extend(std::iter::repeat_n(up, g.len())); + shaped.extend(g.into_iter().map(|g| ShapedGlyph { cluster: g.cluster + r.start, ..g })); + } + // Upright glyphs fill the ideographic em box across the line. + let (em_top, em_bottom) = face.em_box(); + let k_v = k * p.v_scale; let n = shaped.len(); let fref = FaceRef::of(face); for (gi, sg) in shaped.iter().enumerate() { + let up = upright.get(gi).copied().unwrap_or(false); let (byte, len, ch) = if replacement.is_some() { (range.start, if gi == 0 { range.len() } else { 0 }, text[range.start..].chars().next().unwrap_or(' ')) } else { @@ -803,7 +831,14 @@ fn shape_segment( (cl, end.saturating_sub(cl), text[cl..].chars().next().unwrap_or(' ')) }; let last_in_cluster = gi + 1 == n || shaped[gi + 1].cluster != sg.cluster; - let mut adv = sg.x_advance as f64 * k * hs; + // Upright: down the line is the shaper's -y, across it (text space -y) its x. + let mut adv = if up { -(sg.y_advance as f64) * k_v } else { sg.x_advance as f64 * k * hs }; + let (dx, dy) = if up { + (-(sg.y_offset as f64) * k_v, -(sg.x_offset as f64) * k * hs) + } else { + (sg.x_offset as f64 * k * hs, -(sg.y_offset as f64) * k * p.v_scale) + }; + let (ascent, descent) = if up { (em_top * k_v, -em_bottom * k_v) } else { (ascent, descent) }; if ch == SOFT_HYPHEN { adv = 0.0; } else if last_in_cluster { @@ -821,8 +856,8 @@ fn shape_segment( len: if first_in_cluster || replacement.is_some() { len } else { 0 }, ch, adv, - dx: sg.x_offset as f64 * k * hs + mark_x, - dy: -(sg.y_offset as f64) * k * p.v_scale - mark_y, + dx: dx + mark_x, + dy: dy - mark_y, sx: k * hs, sy: k * p.v_scale, shift, @@ -855,10 +890,26 @@ fn shape_segment( safe_tatweel_before: sg.safe_tatweel_before, shaping_rtl: sg.rtl, lang, + upright: up, }); } } +/// `src` cut into runs set upright in vertical text (CJK) and runs that turn with the line, as +/// (byte range, upright); combining marks stay with the run they follow. One horizontal run when +/// `vertical` is false. +fn upright_runs(src: &str, vertical: bool) -> Vec<(std::ops::Range, bool)> { + let mut runs: Vec<(std::ops::Range, bool)> = Vec::new(); + for (i, c) in src.char_indices() { + let up = vertical && crate::upright_in_vertical(c); + match runs.last_mut() { + Some((r, u)) if *u == up || is_mark(c) => r.end = i + c.len_utf8(), + _ => runs.push((i..i + c.len_utf8(), up)), + } + } + runs +} + /// A hyphen glyph in the face/size of `g`, placed after it (zero source length). pub(crate) fn hyphen_after(g: &Glyph) -> Glyph { let gid = designcraft_fonts::first_glyph(&g.face, &['-', '\u{2010}']); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index c88cfa8..faa3708 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -994,6 +994,20 @@ fn tate_chu_yoko_sets_digits_across_one_em() { let [a1, x1, _] = digits[1].tcy.unwrap(); assert!(((digits[0].x + a0) - (digits[1].x + a1)).abs() < 1e-6); assert!(x0 < 0.0 && x1 > x0 && x1 < em, "{x0} {x1}"); + // Offsets move the turned group: the Y offset along the line, the X offset across it. + let origin = |d: &Document| { + let cs = compose_story(d, sid, &ComposeOptions::default()); + let line = &cs.frames[0].lines[0]; + let g = line.glyphs.iter().find(|g| g.tcy.is_some()).unwrap(); + g.vertical_xf(line.baseline).unwrap() * Point::new(g.x, line.baseline + g.y) + }; + let plain = origin(&d); + d.story_mut(sid).unwrap().format_chars(at..at + 2, |f| { + f.over.tate_chu_yoko_x_offset = Some(2.0); + f.over.tate_chu_yoko_y_offset = Some(3.0); + }); + let moved = origin(&d); + assert!(((moved.x - plain.x) - 3.0).abs() < 1e-6 && ((moved.y - plain.y).abs() - 2.0).abs() < 1e-6, "{plain:?} {moved:?}"); } #[test] @@ -1589,3 +1603,158 @@ fn korean_lines_keep_kinsoku() { } } } + +/// A synthetic font for vertical text: Source Sans 3 glyphs (CJK characters drawn as Latin ones) +/// with vertical metrics, every glyph one em down the line and 120 units below its vertical +/// origin, except ヸ (drawn as O): 1.024 em. No vertical forms. Added to the global database once. +fn vertical_test_font() -> &'static str { + use designcraft_fonts::testing::{font_mapping, with_vmtx}; + const FAMILY: &str = "DC Test Vertical"; + static ADDED: std::sync::Once = std::sync::Once::new(); + ADDED.call_once(|| { + let glyphs = [('一', 'X'), ('二', 'X'), ('日', 'X'), ('本', 'X'), ('ま', 'o'), ('ヸ', 'O'), ('、', ','), ('A', 'A'), ('b', 'b')]; + let base = font_mapping(FAMILY, &glyphs).unwrap(); + let scratch = designcraft_fonts::FontDb::with_font_dirs(vec![]); + scratch.add_font(base.clone()); + let wi = u16::try_from(scratch.face(FAMILY, "Regular").glyph_for('ヸ')).unwrap(); + designcraft_fonts::FontDb::global().add_font(with_vmtx(&base, (1000, 120), &[(wi, 1024, 100)]).unwrap()); + }); + FAMILY +} + +/// Shippori Mincho (vertical forms, `vpal`) when built with craft-fonts; else `None`, with a note. +fn shippori() -> Option<&'static str> { + if !designcraft_fonts::japanese_fonts().any(|f| f.family == "Shippori Mincho") { + eprintln!("skipped Shippori Mincho: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)"); + return None; + } + Some("Shippori Mincho") +} + +/// The fonts vertical layout is checked in: the synthetic one, and Shippori Mincho with craft-fonts. +fn vertical_families() -> Vec<&'static str> { + std::iter::once(vertical_test_font()).chain(shippori()).collect() +} + +/// A vertical frame (no inset) with `text` in `family` at 20 pt (and `over`), and its first line +/// with the frame's first column. +fn vertical_line(family: &str, text: &str, over: impl Fn(&mut designcraft_doc::CharFormat)) -> (Line, Rect) { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 500.0), lid, text, ParaFormat::default()).unwrap(); + if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { + tf.options.vertical = true; + tf.options.inset = [0.0; 4]; + } + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| { + f.over.font_family = Some(family.into()); + f.over.size = Some(20.0); + over(f); + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ft = &cs.frames[0]; + assert!(ft.vertical); + let line = ft.lines[0].clone(); + assert!(line.glyphs.iter().all(|g| g.face.family == family), "{family}: {:?}", line.glyphs.iter().map(|g| &g.face.family).collect::>()); + (line, ft.columns[0]) +} + +/// Text-space bounds of a glyph's ink as drawn in a vertical frame. +fn vertical_ink(g: &PlacedGlyph, baseline: f64) -> Rect { + use designcraft_geom::{Affine, Shape}; + let ink = designcraft_fonts::FontDb::global().outline(&g.face, g.gid).bounding_box(); + let mut a = Affine::translate((g.x, baseline + g.y)) * Affine::scale_non_uniform(g.sx, g.sy); + if let Some(turn) = g.vertical_xf(baseline) { + a = turn * a; + } + a.transform_rect_bbox(ink) +} + +/// The em box in text space: along the line from x (page down), across from its top (text -y, +/// page right) to its bottom → (right, left, centre). +fn em_across(l: &Line, g: &PlacedGlyph) -> (f64, f64, f64) { + let (top, bottom) = g.face.em_box(); + let right = l.baseline + g.y - top * g.sy; + let left = l.baseline + g.y - bottom * g.sy; + (right, left, (right + left) / 2.0) +} + +#[test] +fn upright_glyphs_hang_on_the_line_centre() { + for family in vertical_families() { + let (l, _) = vertical_line(family, "一", |_| {}); + let ichi = &l.glyphs[0]; + assert!(ichi.upright, "{family}"); + // An ideograph is centred across the line and inside its em along it. + let (_, _, centre) = em_across(&l, ichi); + let ink = vertical_ink(ichi, l.baseline); + assert!(((ink.y0 + ink.y1) / 2.0 - centre).abs() < 0.05 * 20.0, "{family}: {ink:?} {centre}"); + assert!(ink.x0 > ichi.x && ink.x1 < ichi.x + ichi.adv, "{family}: {ink:?}"); + } + // The synthetic glyph's top is its top side bearing (120 units) below its vertical origin. + let (l, _) = vertical_line(vertical_test_font(), "一", |_| {}); + let ink = vertical_ink(&l.glyphs[0], l.baseline); + assert!((ink.x0 - l.glyphs[0].x - 2.4).abs() < 1e-6, "{ink:?} {}", l.glyphs[0].x); +} + +#[test] +fn upright_punctuation_takes_its_vertical_form_and_place() { + // A font with vertical forms: Shippori Mincho. + let Some(family) = shippori() else { return }; + let (l, _) = vertical_line(family, "一、", |_| {}); + let comma = &l.glyphs[1]; + assert!(comma.upright); + assert_ne!(comma.gid, comma.face.glyph_for('、'), "vertical form (vert)"); + let (right, _, centre) = em_across(&l, comma); + // The comma's ink sits in the upper right quarter of its em box on the page. + let ink = vertical_ink(comma, l.baseline); + assert!(ink.x0 >= comma.x - 1e-6 && ink.x1 < comma.x + comma.adv / 2.0, "upper half: {ink:?} {} {}", comma.x, comma.adv); + assert!(ink.y0 >= right - 1e-6 && ink.y1 < centre, "right half: {ink:?} {right} {centre}"); +} + +#[test] +fn latin_in_vertical_text_turns_as_a_run() { + // Shippori Mincho's `vrt2` has turned Latin forms; a turned run must not use them. + for family in vertical_families() { + let (l, _) = vertical_line(family, "日本Ab", |_| {}); + let latin: Vec<_> = l.glyphs.iter().filter(|g| g.byte >= "日本".len()).collect(); + let face = latin[0].face; + assert_eq!(latin.iter().map(|g| g.gid).collect::>(), [face.glyph_for('A'), face.glyph_for('b')], "{family}"); + assert!(latin.iter().all(|g| !g.upright && g.vertical_xf(l.baseline).is_none()), "{family}"); + assert!((latin[0].adv - face.advance(latin[0].gid) * latin[0].sx).abs() < 1e-9, "{family}: horizontal advance"); + } +} + +#[test] +fn upright_glyphs_advance_by_their_vertical_metrics() { + // ヸ is 1.024 em tall in both fonts. + for family in vertical_families() { + let (l, _) = vertical_line(family, "一ヸ一", |_| {}); + let g = &l.glyphs; + assert_eq!(g[1].face.v_advance(g[1].gid), 1024.0, "{family}"); + assert!((g[1].x - g[0].x - 20.0).abs() < 1e-9, "{family}"); + assert!((g[2].x - g[1].x - 20.48).abs() < 1e-9, "{family}: {} {}", g[1].x, g[2].x); + } + let Some(family) = shippori() else { return }; + // Shippori's ヸ is an em wide. + let (l, _) = vertical_line(family, "ヸ", |_| {}); + assert_eq!(l.glyphs[0].face.advance(l.glyphs[0].gid), 1000.0); + // Proportional vertical metrics (`vpal`): ま takes 0.974 em. + let (l, _) = vertical_line(family, "まま", |f| f.over.otf_features = Some(vec!["vpal".into()])); + assert!((l.glyphs[1].x - l.glyphs[0].x - 19.48).abs() < 1e-9, "{:?}", l.glyphs.iter().map(|g| g.x).collect::>()); + let (l, _) = vertical_line(family, "まま", |_| {}); + assert!((l.glyphs[1].x - l.glyphs[0].x - 20.0).abs() < 1e-9); +} + +#[test] +fn vertical_lines_fit_the_em_box() { + // First baseline at the ascent: an upright line's ascent is its em box top, so the em box + // touches the frame's right edge. The synthetic font's em box is Source Sans 3's BASE one + // (0.83 em, -0.17 em); Shippori Mincho's its OS/2 typo metrics (0.88 em, -0.12 em). + let expected = [(vertical_test_font(), 16.6, 3.4)].into_iter().chain(shippori().map(|f| (f, 17.6, 2.4))); + for (family, ascent, descent) in expected { + let (l, col) = vertical_line(family, "一二", |_| {}); + assert!((l.ascent - ascent).abs() < 1e-9 && (l.descent - descent).abs() < 1e-9, "{family}: {} {}", l.ascent, l.descent); + assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0); + } +} diff --git a/docs/cjk.md b/docs/cjk.md index 50ceb5a..6955571 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -32,7 +32,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Area | State | |---|---| | Vertical frames, upright CJK, rotated Latin | done (per frame; glyphs turned by transforms) | -| `vert`/`vrt2` in vertical frames | done (applied to every run, Latin included); Horizontal Kana (`hkna`) becomes `vkna` | +| Vertical metrics and shaping | done: upright runs shaped top to bottom (`vert`; `vkrn`/`vpal` when asked) with `vmtx`/`VORG` advances and origins, centred on the ideographic em box (`BASE`, else OS/2); turned runs shaped horizontally, without `vrt2`; Horizontal Kana (`hkna`) takes the vertical kana (`vkna`) in upright runs. Default location of variable fonts only (no `VVAR`) | | Kinsoku | named (hard, soft, Chinese, Korean) and custom kinsoku sets, bunri-kinshi, rensuuji, hanging punctuation (regular, force); without a set, fixed no-start / no-end lists and a break anywhere between CJK characters; kinsoku type (push in / push out priorities) kept but not applied | | Korean line breaking | at spaces (Hangul everywhere, hanja in Korean text); per-paragraph character-based breaking (`koreanCharBreaks`); DesignCraft-only, not in IDML | | Mojikumi | tables and paragraph references kept through IDML; not applied (Preflight says so) | @@ -47,8 +47,6 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built Known bugs to fix first: -- Vertical advances are the horizontal ones and upright glyphs turn about a guessed centre - (`adv × 0.38`), so proportional and non-square glyphs sit wrong in vertical text. - A justified CJK line has no stretch except Single Word Justification. ## Architecture From 6df33fa3446da9cb7e9817a03c9dc99135823fe4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 09:09:35 -0400 Subject: [PATCH 08/17] CJK 1.7: story direction belongs to the story Vertical is a story property: every frame of a thread is vertical or horizontal together, a frame threaded onto a story takes its direction, and frames split off by threading or breaking a thread keep theirs. Type > Story Direction is type.storyDirection {vertical} (checked in the menu) for the stories of the text selection or selected frames; object.textFrameOptions {vertical} and frame.create {vertical} set the frames' stories. Documents that stored Vertical on the frame open with each story taking its first frame's direction. IDML StoryOrientation is read into and written from the story, beside its column direction (StoryDirection), which stays a separate setting. --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 13 ++-- crates/compose/src/notes.rs | 1 + crates/compose/src/table.rs | 1 + crates/compose/src/tests.rs | 16 +++-- crates/doc/src/edit.rs | 29 ++++++++- crates/doc/src/item.rs | 21 ------- crates/doc/src/story.rs | 5 ++ crates/doc/src/tests.rs | 52 +++++++++++++++ crates/engine/src/cmd/anchored.rs | 2 +- crates/engine/src/cmd/object.rs | 16 ++--- crates/engine/src/cmd/paths.rs | 3 +- crates/engine/src/cmd/table.rs | 4 +- crates/engine/src/cmd/text.rs | 101 +++++++++++++++++++++++++++++- crates/format/src/lib.rs | 98 ++++++++++++++++++++++++++++- crates/idml/src/export.rs | 3 +- crates/idml/src/import.rs | 8 +-- crates/idml/src/tests.rs | 16 +++-- crates/pdf/src/export.rs | 2 +- crates/pdf/src/links.rs | 2 +- crates/render/src/lib.rs | 2 +- crates/ui-egui/src/canvas.rs | 14 ++--- crates/ui-egui/src/menus.rs | 22 ++++++- docs/cjk.md | 5 +- 24 files changed, 356 insertions(+), 82 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6f60cf4..c7973d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. - Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture. - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. -- Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. +- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. - Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 43eb9e1..8f7685f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -277,6 +277,9 @@ pub struct FrameSpec { /// Text area (inner space, after inset). pub area: Rect, pub opts: TextFrameOptions, + /// Lines run top to bottom and follow each other right to left (the story is vertical); the + /// area is the turned box. + pub vertical: bool, pub exclusions: Vec, pub page_name: Option, /// Absolute document page the frame is on (None on parent pages). @@ -398,7 +401,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { None => (item.text_area(), tf.options.clone()), }; // Vertical type: composed in the turned box; wraps and page rects turned with it. - let (area, exclusions, grid, page_rect) = if tf.options.vertical && tf.options.path.is_none() { + let vertical = doc.frame_vertical(item); + let (area, exclusions, grid, page_rect) = if vertical { let v = designcraft_doc::vertical_text_xf(area).inverse(); let ex = exclusions.into_iter().map(|e| Exclusion { rect: v.transform_rect_bbox(e.rect), ..e }).collect(); let pr = page_rect.map(|(a, b)| (v.transform_rect_bbox(a), v.transform_rect_bbox(b))); @@ -410,6 +414,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { id: fid, area, opts, + vertical, exclusions: if tf.options.path.is_some() { vec![] } else { exclusions }, page_name, page, @@ -445,7 +450,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .iter() .map(|f| { let mut columns = f.columns(); - if story.direction == designcraft_doc::TextDirection::RightToLeft && !f.opts.vertical { + if story.direction == designcraft_doc::TextDirection::RightToLeft && !f.vertical { columns.reverse(); } columns @@ -454,7 +459,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C out.frames = frames .iter() .zip(&cols) - .map(|(f, columns)| FrameText { frame: f.id, vertical: f.opts.vertical, columns: columns.clone(), ..Default::default() }) + .map(|(f, columns)| FrameText { frame: f.id, vertical: f.vertical, columns: columns.clone(), ..Default::default() }) .collect(); let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, last_reference: 0.0, pending: 0.0 }; let para_ranges = story.para_ranges(); @@ -547,7 +552,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C section_marker: None, vars: var_values, hidden_conditions: doc.conditions.iter().filter(|c| !c.visible).map(|c| c.name.clone()).collect(), - vertical: cur_frame.is_some_and(|f| f.opts.vertical), + vertical: cur_frame.is_some_and(|f| f.vertical), ..Default::default() }; if !story.endnotes.is_empty() { diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index d806b54..32b2bbe 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -99,6 +99,7 @@ impl Notes { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), opts: TextFrameOptions { first_baseline: o.first_baseline, first_baseline_min: o.first_baseline_min, ..Default::default() }, + vertical: false, exclusions: vec![], page_name: f.page_name.clone(), page: f.page, diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 1294933..05d97a2 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -93,6 +93,7 @@ fn compose_cell(doc: &Document, story: &Story, w: f64, opts: &ComposeOptions, le id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), opts: TextFrameOptions::default(), + vertical: false, exclusions: vec![], page_name: opts.page_name.clone(), page: opts.page, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index faa3708..07ea456 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -972,15 +972,13 @@ fn ruby_and_kenten_sit_over_their_text() { fn tate_chu_yoko_sets_digits_across_one_em() { let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); let lid = d.default_layer(); - let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 400.0), lid, "令和12年", ParaFormat::default()).unwrap(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 400.0), lid, "令和12年", ParaFormat::default()).unwrap(); let at = "令和".len(); d.story_mut(sid).unwrap().format_chars(at..at + 2, |f| f.over.tate_chu_yoko = Some(true)); let glyphs = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone(); // Horizontal text ignores it. assert!(glyphs(&d).iter().all(|g| g.tcy.is_none())); - if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { - tf.options.vertical = true; - } + d.story_mut(sid).unwrap().vertical = true; let gs = glyphs(&d); let digits: Vec<_> = gs.iter().filter(|g| g.tcy.is_some()).collect(); assert_eq!(digits.len(), 2); @@ -1017,9 +1015,9 @@ fn vertical_frames_compose_in_the_turned_box() { let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 400.0), lid, "縦書きの文章です。Latin", ParaFormat::default()).unwrap(); if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { - tf.options.vertical = true; tf.options.inset = [0.0; 4]; } + d.story_mut(sid).unwrap().vertical = true; let cs = compose_story(&d, sid, &ComposeOptions::default()); let ft = &cs.frames[0]; assert!(ft.vertical); @@ -1030,9 +1028,9 @@ fn vertical_frames_compose_in_the_turned_box() { assert!(l.glyphs.iter().filter(|g| g.len > 0).take(5).all(|g| g.upright)); assert!(!l.glyphs.iter().rev().find(|g| g.len > 0).unwrap().upright, "Latin turns"); // Text space → frame: the first line sits at the right edge and runs down. - let it = d.item(fid).unwrap(); - let p0 = it.text_xf() * designcraft_geom::Point::new(l.glyphs[0].x, l.baseline); - let p1 = it.text_xf() * designcraft_geom::Point::new(l.glyphs[3].x, l.baseline); + let xf = d.text_xf(d.item(fid).unwrap()); + let p0 = xf * designcraft_geom::Point::new(l.glyphs[0].x, l.baseline); + let p1 = xf * designcraft_geom::Point::new(l.glyphs[3].x, l.baseline); assert!(p0.x > 150.0 && p1.y > p0.y && (p1.x - p0.x).abs() < 1e-6, "{p0:?} {p1:?}"); } @@ -1643,9 +1641,9 @@ fn vertical_line(family: &str, text: &str, over: impl Fn(&mut designcraft_doc::C let lid = d.default_layer(); let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 500.0), lid, text, ParaFormat::default()).unwrap(); if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { - tf.options.vertical = true; tf.options.inset = [0.0; 4]; } + d.story_mut(sid).unwrap().vertical = true; d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| { f.over.font_family = Some(family.into()); f.over.size = Some(20.0); diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 8e4e662..188f514 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use designcraft_geom::{Point, Rect, shapes}; +use designcraft_geom::{Affine, Point, Rect, shapes}; use serde::{Deserialize, Serialize}; use crate::ids::{ItemId, LayerId, StoryId}; @@ -252,6 +252,8 @@ impl Document { let nid = StoryId(self.alloc()); let mut ns = Story::new(nid); ns.frames = before.clone(); + ns.vertical = other.vertical; + ns.direction = other.direction; for f in &before { if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { t.story = nid; @@ -276,12 +278,12 @@ impl Document { /// stays with the first part and may become overset). pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> { let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; - let tail = { + let (tail, vertical, direction) = { let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?; let tail = st.frames.split_off(p + 1); st.rev += 1; - tail + (tail, st.vertical, st.direction) }; if tail.is_empty() { return Ok(()); @@ -289,6 +291,8 @@ impl Document { let nid = StoryId(self.alloc()); let mut ns = Story::new(nid); ns.frames = tail.clone(); + ns.vertical = vertical; + ns.direction = direction; self.stories.insert(nid, Arc::new(ns)); for f in tail { if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) { @@ -310,6 +314,24 @@ impl Document { p.checked_sub(1).map(|i| st.frames[i]) } + /// Does this text frame set its text vertically (its story is vertical; type on a path stays + /// along the path)? + pub fn frame_vertical(&self, item: &Item) -> bool { + item.text_frame().is_some_and(|t| t.options.path.is_none() && self.story(t.story).is_some_and(|s| s.vertical)) + } + + /// Text space → item inner space for a frame's composed text (identity except for vertical + /// frames). + pub fn text_local(&self, item: &Item) -> Affine { + if self.frame_vertical(item) { crate::item::vertical_text_xf(item.text_area()) } else { Affine::IDENTITY } + } + + /// Text space → spread-parent space for a frame's composed text (the item transform, with the + /// quarter turn of a vertical frame). + pub fn text_xf(&self, item: &Item) -> Affine { + item.xf * self.text_local(item) + } + /// Hit test: the frontmost visible, unlocked-layer item on spread `si` containing `p` (spread space). /// Frames hit by area if they have a fill or are text/graphic frames; otherwise near the outline. pub fn hit_item(&self, si: usize, p: Point, tol: f64) -> Option { @@ -348,6 +370,7 @@ impl Document { "direction": st.direction, "paragraphs": st.paras.len(), "frames": st.frames.iter().map(|f| f.0).collect::>(), + "vertical": st.vertical, "text": st.text, })) } diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 8a20ec7..5492196 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -332,9 +332,6 @@ pub struct TextFrameOptions { /// Type on a Path: the text runs along the item's path instead of filling it. #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, - /// Vertical Type: lines run top to bottom and follow each other right to left. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub vertical: bool, } /// Type on a Path Options. @@ -380,7 +377,6 @@ impl Default for TextFrameOptions { column_rule_color: designcraft_color::swatch::BLACK.into(), baseline_grid: None, path: None, - vertical: false, } } } @@ -1146,23 +1142,6 @@ impl Item { } /// The frame's text area (inner space) after inset. - /// Text space → item inner space (identity except for vertical frames). - pub fn text_local(&self) -> Affine { - match self.text_frame() { - Some(t) if t.options.vertical && t.options.path.is_none() => vertical_text_xf(self.text_area()), - _ => Affine::IDENTITY, - } - } - - /// Text space → spread-parent space for this frame's composed text (the item transform, with - /// the quarter turn of a vertical frame). - pub fn text_xf(&self) -> Affine { - match self.text_frame() { - Some(t) if t.options.vertical && t.options.path.is_none() => self.xf * vertical_text_xf(self.text_area()), - _ => self.xf, - } - } - pub fn text_area(&self) -> Rect { let r = self.inner_bounds(); let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]); diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 398cf99..21ac81b 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -95,6 +95,10 @@ pub struct Story { /// Column progression, independent of paragraph bidi direction and frame threading. #[serde(default)] pub direction: crate::TextDirection, + /// Story direction: vertical stories set their lines top to bottom, following each other + /// right to left, in every frame of the thread. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub vertical: bool, /// Bumped on every edit (composition cache key). #[serde(default)] pub rev: u64, @@ -136,6 +140,7 @@ impl Story { chars: vec![CharRun { len: 0, format: CharFormat::default() }], frames: vec![], direction: crate::TextDirection::LeftToRight, + vertical: false, rev: 0, tables: BTreeMap::new(), notes: Vec::new(), diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index c3ba44d..9ee28a1 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -42,6 +42,58 @@ fn thread_to_empty_frame_converts_it() { assert!(d.thread(a, a).is_err()); } +#[test] +fn frames_keep_their_story_direction_through_threading() { + let mut d = doc(); + let lid = d.default_layer(); + let r = |x: f64| Rect::new(x, 10.0, x + 50.0, 100.0); + let vertical = |d: &Document, f: ItemId| d.item(f).is_some_and(|i| d.frame_vertical(i)); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), r(0.0), lid, "縦", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), r(60.0), lid, "", ParaFormat::default()).unwrap(); + d.story_mut(sa).unwrap().vertical = true; + // A horizontal frame threaded onto a vertical story turns vertical. + d.thread(a, b).unwrap(); + assert!(vertical(&d, a) && vertical(&d, b)); + // Threading the vertical story's second frame after a horizontal frame: it turns horizontal, + // and the frame left before it stays vertical. + let (h, _) = d.add_text_frame(SpreadRef::Doc(0), r(120.0), lid, "横", ParaFormat::default()).unwrap(); + d.thread(h, b).unwrap(); + assert!(vertical(&d, a) && !vertical(&d, b) && !vertical(&d, h)); + // Breaking a vertical thread leaves the frames after the break vertical. + let (c, _) = d.add_text_frame(SpreadRef::Doc(0), r(180.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, c).unwrap(); + d.unthread_after(a).unwrap(); + assert!(vertical(&d, a) && vertical(&d, c)); + assert_ne!(d.item(c).unwrap().text_frame().unwrap().story, sa); + d.check().unwrap(); +} + +#[test] +fn frames_keep_their_column_direction_through_threading() { + use crate::TextDirection::{LeftToRight, RightToLeft}; + let mut d = doc(); + let lid = d.default_layer(); + let r = |x: f64| Rect::new(x, 10.0, x + 50.0, 100.0); + let direction = |d: &Document, f: ItemId| d.story(d.item(f).unwrap().text_frame().unwrap().story).unwrap().direction; + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), r(0.0), lid, "نص", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), r(60.0), lid, "", ParaFormat::default()).unwrap(); + d.story_mut(sa).unwrap().direction = RightToLeft; + d.thread(a, b).unwrap(); + assert_eq!((direction(&d, a), direction(&d, b)), (RightToLeft, RightToLeft)); + // Threading the right-to-left story's second frame after a left-to-right frame: the frame + // left before it stays right to left. + let (h, _) = d.add_text_frame(SpreadRef::Doc(0), r(120.0), lid, "abc", ParaFormat::default()).unwrap(); + d.thread(h, b).unwrap(); + assert_eq!((direction(&d, a), direction(&d, b), direction(&d, h)), (RightToLeft, LeftToRight, LeftToRight)); + // Breaking a right-to-left thread leaves the frames after the break right to left. + let (c, _) = d.add_text_frame(SpreadRef::Doc(0), r(180.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, c).unwrap(); + d.unthread_after(a).unwrap(); + assert_ne!(d.item(c).unwrap().text_frame().unwrap().story, sa); + assert_eq!((direction(&d, a), direction(&d, c)), (RightToLeft, RightToLeft)); + d.check().unwrap(); +} + #[test] fn removing_last_frame_deletes_story() { let mut d = doc(); diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index 8e74baf..8c85c70 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -207,7 +207,7 @@ fn release(s: &mut Session, p: &Value) -> Result { let (spread, xf) = match placed { Some((fid, origin)) => { let loc = d0.find(fid).ok_or_else(|| bad("anchored.release", "frame not found"))?; - let fxf = d0.parent_xf(&loc) * d0.item(fid).map_or(Affine::IDENTITY, |i| i.text_xf()); + let fxf = d0.parent_xf(&loc) * d0.item(fid).map_or(Affine::IDENTITY, |i| d0.text_xf(i)); (loc.spread, fxf * Affine::translate(origin.to_vec2())) } None => (SpreadRef::Doc(0), Affine::IDENTITY), diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 60d5d10..40802d5 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Create Frame", [], None, - "{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool}", + "{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}", has_doc, frame_create ), @@ -479,7 +479,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r], verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, vertical?: bool (Vertical Type), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r], verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -815,10 +815,8 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { if let Some(it) = d.item_mut(id) { it.path = path; it.shape = sh; - if vertical && let Some(tf) = it.text_frame_mut() { - tf.options.vertical = true; - } } + super::text::set_story_direction(d, &[sid], vertical); *sel = if caret { Selection::text(TextSel { story: sid, anchor: text.len(), focus: text.len(), frame: Some(id), cell: None }) } else { @@ -1452,9 +1450,6 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("ignoreWrap").and_then(Value::as_bool) { o.ignore_wrap = v; } - if let Some(v) = p.get("vertical").and_then(Value::as_bool) { - o.vertical = v; - } if let Some(v) = p.get("balanceColumns").and_then(Value::as_bool) { o.balance_columns = v; } @@ -1462,6 +1457,11 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { o.column_rule = v; } } + // Story direction belongs to the story: every frame of the thread turns. + if let Some(v) = p.get("vertical").and_then(Value::as_bool) { + let stories: Vec = ids.iter().filter_map(|id| d.item(*id).and_then(Item::text_frame).map(|t| t.story)).collect(); + super::text::set_story_direction(d, &stories, v); + } Ok(json!({"changed": ids.len()})) }) } diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 700bfa5..b9d4790 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -273,9 +273,10 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { } let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?; let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id)); + let text_xf = d.text_xf(&it); let make = |d: &mut Document, ((fill, ft, stroke, stt, sw), bp): ((String, f32, String, f32, f64), BezPath)| { let mut o = Item::new(ItemId(d.alloc()), it.layer, Shape::Path, PathData::from_bezpath(&bp)); - o.xf = it.text_xf(); + o.xf = text_xf; o.fill = Fill { swatch: fill, tint: ft, ..Fill::none() }; o.stroke = if stroke == designcraft_color::swatch::NONE { Stroke::none() diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 574b3bf..c2f925c 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1150,7 +1150,7 @@ fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Po let loc = st.doc.find(frame)?; let it = st.doc.item_at(&loc)?; let sid = it.text_frame()?.story; - let inner = (st.doc.parent_xf(&loc) * it.text_xf()).inverse() * pt; + let inner = (st.doc.parent_xf(&loc) * st.doc.text_xf(it)).inverse() * pt; let cs = s.cache.get(&st.doc, sid, None); let fi = cs.frames.iter().position(|f| f.frame == frame)?; let (table, row, col, _) = designcraft_compose::hit_cell(&cs, fi, inner)?; @@ -1396,7 +1396,7 @@ mod sort_tests { let cells = &cs.frames[0].tables[0].cells; let centre = |r: usize| { let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); - d.item(fid).unwrap().text_xf() * c + d.text_xf(d.item(fid).unwrap()) * c }; let (from, to) = (centre(0), centre(2)); s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 6a68435..2885704 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -111,7 +111,7 @@ pub fn specs() -> Vec { }) } ), - cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, overset", has_doc, |s, p| { + cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| { let st = s.doc()?; let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?; let cs = s.cache.get(&st.doc, sid, None); @@ -206,6 +206,15 @@ pub fn specs() -> Vec { format_chars(s, &json!({"kenten": on})) } ), + cmd!( + "type.storyDirection", + "Story Direction", + [], + None, + "{vertical: bool} — the stories of the text selection or the selected frames: vertical lines run top to bottom and follow each other right to left, in every frame of the thread", + has_text_or_frames, + story_direction + ), cmd!("type.sizeUp", "Increase Point Size", [], Some("Cmd+Shift+."), "{}", has_text_or_frames, |s, _| step_size(s, 2.0)), cmd!("type.sizeDown", "Decrease Point Size", [], Some("Cmd+Shift+,"), "{}", has_text_or_frames, |s, _| step_size(s, -2.0)), cmd!( @@ -257,7 +266,7 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op let loc = st.doc.find(frame)?; let it = st.doc.item_at(&loc)?; let sid = it.text_frame()?.story; - let xf = st.doc.parent_xf(&loc) * it.text_xf(); + let xf = st.doc.parent_xf(&loc) * st.doc.text_xf(it); let inner = xf.inverse() * pt; let cs = s.cache.get(&st.doc, sid, None); let fi = cs.frames.iter().position(|f| f.frame == frame)?; @@ -596,6 +605,36 @@ fn word_bounds(s: &str, i: usize) -> (usize, usize) { (a, b) } +/// Type ▸ Story Direction. +fn story_direction(s: &mut Session, p: &Value) -> Result { + let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; + let st = s.doc()?; + let mut stories: Vec = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect(); + for id in &st.selection.items { + if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) + && !stories.contains(&tf.story) + { + stories.push(tf.story); + } + } + s.edit(|d, _| { + set_story_direction(d, &stories, vertical); + Ok(json!({"stories": stories.len()})) + }) +} + +/// Set the direction of `stories` (all their frames turn together). +pub(crate) fn set_story_direction(d: &mut designcraft_doc::Document, stories: &[StoryId], vertical: bool) { + for sid in stories { + if let Some(st) = d.story_mut(*sid) + && st.vertical != vertical + { + st.vertical = vertical; + st.rev += 1; + } + } +} + fn move_caret(s: &mut Session, p: &Value) -> Result { let dir = str_param(p, "dir").unwrap_or("right").to_string(); let extend = bool_or(p, "extend", false); @@ -1396,3 +1435,61 @@ mod korean_breaks_tests { assert_eq!(s.doc().unwrap().doc.styles.para("Korean").unwrap().para.korean_char_breaks, Some(true)); } } + +#[cfg(test)] +mod story_direction_tests { + use designcraft_doc::{ItemId, StoryId}; + use serde_json::json; + + use crate::Session; + + fn frame(s: &mut Session, rect: [f64; 4], text: &str, vertical: bool) -> (ItemId, StoryId) { + let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false, "vertical": vertical})).unwrap(); + (ItemId(r["id"].as_u64().unwrap()), StoryId(r["story"].as_u64().unwrap())) + } + + /// Which frames of the story are composed vertically, in thread order. + fn composed(s: &Session, sid: StoryId) -> Vec { + s.cache.get(&s.doc().unwrap().doc, sid, None).frames.iter().map(|f| f.vertical).collect() + } + + #[test] + fn story_direction_turns_every_frame_of_the_story() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", false); + let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false); + s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); + assert_eq!(composed(&s, sid), [false, false]); + // From the second frame selected, through Type ▸ Story Direction. + s.execute("selection.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("type.storyDirection", &json!({"vertical": true})).unwrap(); + assert_eq!(composed(&s, sid), [true, true]); + // From a caret in the text. + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.storyDirection", &json!({"vertical": false})).unwrap(); + assert_eq!(composed(&s, sid), [false, false]); + // The frame option sets the frame's story. + s.execute("object.textFrameOptions", &json!({"ids": [a.0], "vertical": true})).unwrap(); + assert_eq!(composed(&s, sid), [true, true]); + // Undo puts it back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(composed(&s, sid), [false, false]); + } + + #[test] + fn a_frame_threaded_onto_a_vertical_story_turns_vertical() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", true); + let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false); + let c = s.execute("frame.create", &json!({"rect": [450, 72, 550, 400]})).unwrap(); + let c = ItemId(c["id"].as_u64().unwrap()); + s.edit(|d, _| { + d.thread(a, b)?; + Ok(d.thread(b, c)?) + }) + .unwrap(); + assert_eq!(composed(&s, sid), [true, true, true]); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5b4896c..6b57e2a 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -9,6 +9,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +use std::collections::HashSet; use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -95,7 +96,9 @@ fn load_zip(bytes: &[u8]) -> Result { return Err(FormatError::TooNew(v as u32)); } let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; - let mut doc: Document = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; + let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; + upgrade(&mut v); + let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?; let names: Vec = z.file_names().map(str::to_string).collect(); for name in names { let Some(stem) = name.strip_prefix("assets/") else { continue }; @@ -109,7 +112,8 @@ fn load_zip(bytes: &[u8]) -> Result { } fn load_legacy_json(bytes: &[u8]) -> Result { - let v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?; + let mut v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?; + upgrade(&mut v); let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?; if let Some(data) = v.get("assetData").and_then(Value::as_object) { for (k, s) in data { @@ -124,6 +128,54 @@ fn load_legacy_json(bytes: &[u8]) -> Result { Ok(doc) } +/// Bring older documents' JSON up to the current model. Vertical Type was a text frame option +/// (`content.options.vertical`); it is the story's direction, which a story takes from its first +/// frame. +fn upgrade(doc: &mut Value) { + let mut vertical = HashSet::new(); + vertical_frames(doc, &mut vertical, 0); + if vertical.is_empty() { + return; + } + let Some(stories) = doc.get_mut("stories").and_then(Value::as_object_mut) else { return }; + for st in stories.values_mut() { + let first = st.get("frames").and_then(|f| f.get(0)).and_then(Value::as_u64); + if let (Some(f), Some(st)) = (first, st.as_object_mut()) + && vertical.contains(&f) + && !st.contains_key("vertical") + { + st.insert("vertical".into(), Value::Bool(true)); + } + } +} + +/// Ids of the text frames in `v` (any depth: groups, anchored objects) whose options say vertical. +fn vertical_frames(v: &Value, out: &mut HashSet, depth: usize) { + // serde_json already refuses JSON nested deeper than 128 levels. + if depth > 256 { + return; + } + match v { + Value::Object(m) => { + if let (Some(id), Some(c)) = (m.get("id").and_then(Value::as_u64), m.get("content")) + && c.get("type").and_then(Value::as_str) == Some("text") + && c.get("options").and_then(|o| o.get("vertical")).and_then(Value::as_bool) == Some(true) + { + out.insert(id); + } + for c in m.values() { + vertical_frames(c, out, depth + 1); + } + } + Value::Array(a) => { + for c in a { + vertical_frames(c, out, depth + 1); + } + } + _ => {} + } +} + fn base64_decode(s: &str) -> Vec { let mut out = Vec::with_capacity(s.len() * 3 / 4); let (mut buf, mut bits) = (0u32, 0); @@ -179,6 +231,48 @@ mod tests { assert_eq!(back.stories, d.stories); } + /// The object of the item `id` in a document's JSON. + fn item_json(v: &mut Value, id: u64) -> Option<&mut Value> { + if v.get("id").and_then(Value::as_u64) == Some(id) && v.get("content").is_some() { + return Some(v); + } + match v { + Value::Object(m) => m.values_mut().find_map(|c| item_json(c, id)), + Value::Array(a) => a.iter_mut().find_map(|c| item_json(c, id)), + _ => None, + } + } + + #[test] + fn frame_level_vertical_text_opens_as_a_vertical_story() { + // Documents that set Vertical on the frame: the story takes its first frame's direction. + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, "縦書き", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 72.0, 428.0, 400.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + let (c, other) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(450.0, 72.0, 550.0, 400.0), lid, "横", ParaFormat::default()).unwrap(); + let mut v = serde_json::to_value(&d).unwrap(); + item_json(&mut v, a.0).unwrap()["content"]["options"]["vertical"] = json!(true); + item_json(&mut v, c.0).unwrap()["content"]["options"]["vertical"] = json!(false); + let json = serde_json::to_vec(&v).unwrap(); + let mut zipped = Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut zipped); + z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(&json).unwrap(); + z.finish().unwrap(); + } + for bytes in [json.clone(), zipped.into_inner()] { + let back = load(&bytes).unwrap(); + assert!(back.stories[&sid].vertical); + assert!(!back.stories[&other].vertical); + // Saved again, the direction is the story's. + let again = load(&save(&back).unwrap()).unwrap(); + assert!(again.stories[&sid].vertical && !again.stories[&other].vertical); + } + } + #[test] fn rejects_garbage_and_newer_versions() { assert!(load(b"nope").is_err()); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index aaffe3b..5192aa3 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1837,14 +1837,13 @@ impl<'a> Ex<'a> { // ---------- stories ---------- fn story_el(&mut self, s: &Story) -> El { - let vertical = s.frames.iter().any(|f| self.d.item(*f).and_then(|i| i.text_frame()).is_some_and(|t| t.options.vertical)); let mut el = El::new("Story").attr("Self", uid(s.id.0)).attr("AppliedTOCStyle", "n").attr("TrackChanges", "false").attr("StoryTitle", "$ID/"); el.push( El::new("StoryPreference") .attr("OpticalMarginAlignment", "false") .attr("OpticalMarginSize", "12") .attr("FrameType", "TextFrameType") - .attr("StoryOrientation", if vertical { "Vertical" } else { "Horizontal" }) + .attr("StoryOrientation", if s.vertical { "Vertical" } else { "Horizontal" }) .attr( "StoryDirection", if s.direction == designcraft_doc::TextDirection::RightToLeft { "RightToLeftDirection" } else { "LeftToRightDirection" }, diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index c3ad423..493d6a0 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -115,8 +115,6 @@ struct Importer<'r> { layer_ids: HashMap, stories: BTreeMap, story_ids: HashMap, - /// Stories with a vertical StoryOrientation (their frames set text vertically). - vertical_stories: std::collections::HashSet, parents: Vec, parent_ids: HashMap, spreads: Vec, @@ -199,7 +197,6 @@ impl<'r> Importer<'r> { layers: Vec::new(), layer_ids: HashMap::new(), stories: BTreeMap::new(), - vertical_stories: Default::default(), story_ids: HashMap::new(), parents: Vec::new(), parent_ids: HashMap::new(), @@ -385,9 +382,6 @@ impl<'r> Importer<'r> { self.story_ids.insert(s.to_string(), id); } let story = self.story(id, e); - if e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical") { - self.vertical_stories.insert(id); - } self.stories.insert(id, story); } // Topic cross-references (See / See also) become markers at the start of the first story @@ -1423,6 +1417,7 @@ impl<'r> Importer<'r> { } else { designcraft_doc::TextDirection::LeftToRight }, + vertical: e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical"), rev: 0, tables, notes, @@ -2045,7 +2040,6 @@ impl<'r> Importer<'r> { } }; let mut options = e.find("TextFramePreference").map(text_frame_options).unwrap_or_default(); - options.vertical = self.vertical_stories.contains(&story); if let Some(g) = e.find("BaselineFrameGridOption") && g.get("UseCustomBaselineFrameGrid") == Some("true") { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 9211268..b0eb583 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -554,11 +554,19 @@ fn round_trips_frames_with_pasted_in_items() { fn vertical_story_orientation_round_trips() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); - let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, "縦書き", ParaFormat::default()).unwrap(); - d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical = true; + let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, "縦書き", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 72.0, 428.0, 400.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(450.0, 72.0, 550.0, 400.0), lid, "横", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().vertical = true; + // Orientation and column direction are separate settings of the story. + d.story_mut(sid).unwrap().direction = designcraft_doc::TextDirection::RightToLeft; let back = import_idml(&export_idml(&d)).unwrap(); - let frame = back.spreads[0].items.iter().find_map(|i| i.text_frame()).unwrap(); - assert!(frame.options.vertical); + use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; + let directions: Vec<_> = back.stories.values().map(|s| (s.frames.len(), s.vertical, s.direction)).collect(); + assert!(directions.contains(&(2, true, RightToLeft)) && directions.contains(&(1, false, LeftToRight)), "{directions:?}"); + let frames: Vec = back.spreads[0].items.iter().map(|i| back.frame_vertical(i)).collect(); + assert_eq!(frames, [true, true, false]); } #[test] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index a4e60c3..db2fe6f 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -895,7 +895,7 @@ impl Exporter<'_> { } } else if let Some(ft) = cs.frame(it.id) { let text = doc.story(tfr.story).map(|st| st.text.as_str()).unwrap_or(""); - let local = it.text_local(); + let local = doc.text_local(it); if local != Affine::IDENTITY { s.push_transform(&tf(local)); } diff --git a/crates/pdf/src/links.rs b/crates/pdf/src/links.rs index 393084c..ce81287 100644 --- a/crates/pdf/src/links.rs +++ b/crates/pdf/src/links.rs @@ -32,7 +32,7 @@ fn source_rects(doc: &Document, cache: &designcraft_compose::Cache, src: &Hyperl if loc.spread != SpreadRef::Doc(si) { continue; } - let m: Affine = doc.parent_xf(&loc) * it.text_xf(); + let m: Affine = doc.parent_xf(&loc) * doc.text_xf(it); for l in &ft.lines { if l.range.end <= *start || l.range.start >= *end { continue; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e86478c..6695d2c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -579,7 +579,7 @@ impl Renderer { if let Some(ft) = cs.frame(it.id) { match &tf.options.path { Some(pt) => self.draw_path_text(ctx, f, &cs, ft, xf, bp, pt), - None => self.draw_text(ctx, f, &cs, ft, xf * it.text_local()), + None => self.draw_text(ctx, f, &cs, ft, xf * f.doc.text_local(it)), } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e1562c6..6753764 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -939,7 +939,7 @@ fn draw_text_selection( if let Some(sel) = cells.filter(|c| c.story == ts.story) { for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); for t in ft.tables.iter().filter(|t| t.table == sel.table) { for c in t.cells.iter().filter(|c| sel.range.contains(c.row, c.col)) { let q = [ @@ -962,7 +962,7 @@ fn draw_text_selection( for (fi, ft) in cs.frames.iter().enumerate() { let Some((a, _)) = item_canvas_xf(doc, layout, ft.frame) else { continue }; let Some(it) = doc.item(ft.frame) else { continue }; - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); // Show the frame edge while typing. for poly in path_screen(it, xf, a) { painter.add(egui::Shape::line(poly, Stroke::new(1.0, layer_color(doc, it).gamma_multiply(0.6)))); @@ -1022,7 +1022,7 @@ fn draw_text_selection( && let Some(ft) = cs.frames.get(fi) && let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) { - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { let p0 = xf.to_screen(m * Point::new(x, bl - asc)); @@ -1053,7 +1053,7 @@ fn draw_cell_selection( let Some((fi, origin, text)) = found else { return }; let Some(ft) = cs.frames.get(fi) else { return }; let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return }; - let m = a * it.text_xf() * designcraft_geom::Affine::translate(origin.to_vec2()); + let m = a * doc.text_xf(it) * designcraft_geom::Affine::translate(origin.to_vec2()); let range = ts.range(); if !range.is_empty() && let Some(cft) = text.frames.first() @@ -1081,7 +1081,7 @@ fn draw_cell_selection( None => compose::cell_caret(cs, cell.table, cell.row, cell.col, ts.focus), } { - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { painter.line_segment( @@ -1485,7 +1485,7 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc let cs = app.session.cache.get(doc, story.id, None); for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); let col = layer_color(doc, it); for l in &ft.lines { let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; @@ -1553,7 +1553,7 @@ fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painter: &egui::P let red = Stroke::new(1.0, Color32::from_rgb(230, 30, 30)); for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; - let m = a * it.text_xf(); + let m = a * doc.text_xf(it); for l in &ft.lines { for r in bad.iter().filter(|r| r.start < l.range.end && r.end > l.range.start) { let gs: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= r.start && g.byte < r.end).collect(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c7b9f19..8f5401d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -407,8 +407,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:endnote.insert", "cmd:math.insert", ">Story Direction", - "cmd:object.textFrameOptions|Horizontal|{\"vertical\": false}", - "cmd:object.textFrameOptions|Vertical|{\"vertical\": true}", + "cmd:type.storyDirection|Horizontal|{\"vertical\": false}", + "cmd:type.storyDirection|Vertical|{\"vertical\": true}", "<", "cmd:type.tateChuYoko", "ui:app.rubyDialog", @@ -1681,6 +1681,15 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "window.toolsDoubleColumn" => app.ui.tools_double_column, "view.togglePreview" => app.ui.screen_mode == crate::ScreenMode::Preview, "window.brightness" => params.get("brightness").and_then(Value::as_str) == Some(app.ui.brightness.id()), + "type.storyDirection" => { + let st = app.session.active()?; + let sid = st + .selection + .text + .map(|t| t.story) + .or_else(|| st.selection.items.iter().find_map(|i| st.doc.item(*i)?.text_frame().map(|t| t.story)))?; + st.doc.story(sid)?.vertical == params.get("vertical").and_then(Value::as_bool)? + } _ => return None, }) } @@ -2131,6 +2140,15 @@ mod tests { } } + #[test] + fn story_direction_is_checked() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "縦", "vertical": true})).unwrap(); + assert_eq!(checked(&app, "type.storyDirection", &json!({"vertical": true})), Some(true)); + assert_eq!(checked(&app, "type.storyDirection", &json!({"vertical": false})), Some(false)); + } + #[test] fn proof_setup_and_colors() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/cjk.md b/docs/cjk.md index 6955571..fb7108b 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -31,7 +31,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Area | State | |---|---| -| Vertical frames, upright CJK, rotated Latin | done (per frame; glyphs turned by transforms) | +| Vertical text, upright CJK, rotated Latin | done: story direction per story (frames of a thread agree; IDML `StoryOrientation`); glyphs turned by transforms | | Vertical metrics and shaping | done: upright runs shaped top to bottom (`vert`; `vkrn`/`vpal` when asked) with `vmtx`/`VORG` advances and origins, centred on the ideographic em box (`BASE`, else OS/2); turned runs shaped horizontally, without `vrt2`; Horizontal Kana (`hkna`) takes the vertical kana (`vkna`) in upright runs. Default location of variable fonts only (no `VVAR`) | | Kinsoku | named (hard, soft, Chinese, Korean) and custom kinsoku sets, bunri-kinshi, rensuuji, hanging punctuation (regular, force); without a set, fixed no-start / no-end lists and a break anywhere between CJK characters; kinsoku type (push in / push out priorities) kept but not applied | | Korean line breaking | at spaces (Hangul everywhere, hanja in Korean text); per-paragraph character-based breaking (`koreanCharBreaks`); DesignCraft-only, not in IDML | @@ -105,8 +105,7 @@ working (tsume removes the side bearings before mojikumi runs). shaper reports vertical advances; rotated (Latin) runs stay horizontal and turn as a whole. `vrt2` only for runs that are rotated, never with an extra turn. - Glyph placement in vertical lines uses the vertical origin, not a guessed centre. -- Story direction per story (IDML `StoryOrientation`) rather than per frame; frames of one story - agree. +- Story direction per story (IDML `StoryOrientation`); frames of one story agree. - Arrow keys and carets follow the vertical line direction. ### Annotations and inline structures From e6e843dc2611f9f6a4db0160afb3cd1db6f8f469 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 15:07:37 -0400 Subject: [PATCH 09/17] CJK 1.8: arrow keys follow vertical lines In vertical text Down and Up move along the line by a character (by a word with Alt), Left and Right to the next and previous line keeping the position along it, and Shift extends, as the arrows do in horizontal text turned a quarter. The caret is a bar across the vertical line. --- ROADMAP.md | 2 +- crates/engine/src/cmd/text.rs | 90 ++++++++++++++++++++++++++++++++++- docs/cjk.md | 2 +- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c7973d3..9975367 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. - Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture. - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. -- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. +- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. - Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 2885704..d81e169 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -651,8 +651,18 @@ fn move_caret(s: &mut Session, p: &Value) -> Result { None => cs, }; let pos = t.focus.min(text.len()); + // Vertical lines run top to bottom and follow each other right to left: Down and Up move along + // the line (Right and Left of horizontal text), Left and Right to the next and previous line. + let vertical_text = compose::caret(&cs, pos).and_then(|(fi, ..)| cs.frames.get(fi)).is_some_and(|f| f.vertical); + let dir = match (vertical_text, dir.as_str()) { + (true, "down") => "right", + (true, "up") => "left", + (true, "left") => "down", + (true, "right") => "up", + (_, d) => d, + }; let collapse_to = |left: bool| if left { t.range().start } else { t.range().end }; - let new = match dir.as_str() { + let new = match dir { "left" if !extend && !t.is_caret() => collapse_to(true), "right" if !extend && !t.is_caret() => collapse_to(false), "left" | "right" => { @@ -1493,3 +1503,81 @@ mod story_direction_tests { assert_eq!(composed(&s, sid), [true, true, true]); } } + +#[cfg(test)] +mod vertical_caret_tests { + use designcraft_doc::StoryId; + use serde_json::json; + + use crate::Session; + + /// A story set in three or more lines; returns its id. + fn story(s: &mut Session, vertical: bool) -> StoryId { + s.execute("file.new", &json!({})).unwrap(); + let text = "一二三四五六七八九十".repeat(8); + let rect = if vertical { [72, 72, 200, 400] } else { [72, 72, 400, 200] }; + let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "vertical": vertical})).unwrap(); + StoryId(r["story"].as_u64().unwrap()) + } + + fn mv(s: &mut Session, dir: &str, extend: bool, word: bool) -> usize { + s.execute("text.move", &json!({"dir": dir, "extend": extend, "word": word})).unwrap()["pos"].as_u64().unwrap() as usize + } + + /// (line index, position along the line) of the caret at `pos`. + fn place(s: &Session, sid: StoryId, pos: usize) -> (usize, f64) { + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + let (_, x, baseline, _, _) = crate::compose::caret(&cs, pos).unwrap(); + let line = cs.frames[0].lines.iter().position(|l| (l.baseline - baseline).abs() < 0.01).unwrap(); + (line, x) + } + + #[test] + fn arrow_keys_follow_vertical_lines() { + let mut s = Session::new(); + let sid = story(&mut s, true); + assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).frames[0].lines.len() >= 3); + let c = '一'.len_utf8(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap(); + // Down and Up move along the line, a character at a time. + assert_eq!(mv(&mut s, "down", false, false), 4 * c); + assert_eq!(mv(&mut s, "up", false, false), 3 * c); + // Left moves to the next line (lines follow each other right to left), keeping the + // position along the line; Right comes back. + let (line, x) = place(&s, sid, 3 * c); + let next = mv(&mut s, "left", false, false); + let (nline, nx) = place(&s, sid, next); + assert_eq!(nline, line + 1); + assert!((nx - x).abs() < 0.5, "{x} {nx}"); + assert_eq!(mv(&mut s, "right", false, false), 3 * c); + // Shift extends. + assert_eq!(mv(&mut s, "down", true, false), 4 * c); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (3 * c, 4 * c)); + let p = mv(&mut s, "left", true, false); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (3 * c, p)); + assert_eq!(place(&s, sid, p).0, line + 1); + // With a selection, Up collapses it to its start. + assert_eq!(mv(&mut s, "up", false, false), 3 * c); + // By words: Alt+Down is the vertical Alt+Right. + let text = s.doc().unwrap().doc.story(sid).unwrap().text.clone(); + assert_eq!(mv(&mut s, "down", false, true), super::next_word(&text, 3 * c)); + } + + #[test] + fn arrow_keys_in_horizontal_lines() { + let mut s = Session::new(); + let sid = story(&mut s, false); + let c = '一'.len_utf8(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap(); + assert_eq!(mv(&mut s, "right", false, false), 4 * c); + assert_eq!(mv(&mut s, "left", false, false), 3 * c); + let (line, x) = place(&s, sid, 3 * c); + let next = mv(&mut s, "down", false, false); + let (nline, nx) = place(&s, sid, next); + assert_eq!(nline, line + 1); + assert!((nx - x).abs() < 0.5, "{x} {nx}"); + assert_eq!(mv(&mut s, "up", false, false), 3 * c); + } +} diff --git a/docs/cjk.md b/docs/cjk.md index fb7108b..85cbbe2 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -31,7 +31,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Area | State | |---|---| -| Vertical text, upright CJK, rotated Latin | done: story direction per story (frames of a thread agree; IDML `StoryOrientation`); glyphs turned by transforms | +| Vertical text, upright CJK, rotated Latin | done: story direction per story (frames of a thread agree; IDML `StoryOrientation`); glyphs turned by transforms; arrow keys follow the vertical lines | | Vertical metrics and shaping | done: upright runs shaped top to bottom (`vert`; `vkrn`/`vpal` when asked) with `vmtx`/`VORG` advances and origins, centred on the ideographic em box (`BASE`, else OS/2); turned runs shaped horizontally, without `vrt2`; Horizontal Kana (`hkna`) takes the vertical kana (`vkna`) in upright runs. Default location of variable fonts only (no `VVAR`) | | Kinsoku | named (hard, soft, Chinese, Korean) and custom kinsoku sets, bunri-kinshi, rensuuji, hanging punctuation (regular, force); without a set, fixed no-start / no-end lists and a break anywhere between CJK characters; kinsoku type (push in / push out priorities) kept but not applied | | Korean line breaking | at spaces (Hangul everywhere, hanja in Korean text); per-paragraph character-based breaking (`koreanCharBreaks`); DesignCraft-only, not in IDML | From 5f52150b314c5f0720659bd6a8dd930506eb2279 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 09:11:19 -0400 Subject: [PATCH 10/17] CJK 1.9: document fonts belong to their document The fonts in a document's Document Fonts folder are registered under a font scope that the document holds while it is open (Document::font_scope, runtime only; DocState keeps the scope alive and closes it when the last copy of the state goes: file.close, closing a tab, revert, a book chapter's export, the session ending). Composition, preflight, font.list, Package and the font menus, Glyphs panel and previews look fonts up through FontDb::scoped(scope): the document's own faces first, then the shared ones. Other open documents don't see them, and two documents with fonts of the same name each set text in their own. Composite fonts' member fonts and the fallback for kenten marks are looked up the same way. Font files stay in memory for the session (FaceRef keeps faces for the process); a document opened again reuses the faces of its unchanged files (same path, length and modification time) instead of reading them again. The composition cache's signature includes the scope; the word cache and the outline and PDF font caches are keyed by face. Variable font instances are kept per base face. A family's installed faces join the shared fonts all at once, and a face two lookups load at the same time is kept once, so a parallel fallback lookup never takes a family's bold before its regular has loaded. --- ROADMAP.md | 2 +- crates/compose/src/cache.rs | 4 +- crates/compose/src/lib.rs | 12 +- crates/compose/src/ruby.rs | 11 +- crates/compose/src/shape.rs | 40 +- crates/compose/src/tests.rs | 60 +++ crates/doc/src/build.rs | 1 + crates/doc/src/lib.rs | 5 + crates/engine/src/cmd/book.rs | 6 +- crates/engine/src/cmd/file.rs | 160 ++++++- crates/engine/src/cmd/fonts.rs | 2 +- crates/engine/src/cmd/interchange.rs | 10 +- crates/engine/src/cmd/package.rs | 2 +- crates/engine/src/cmd/preflight.rs | 2 +- crates/engine/src/lib.rs | 15 + crates/engine/src/recovery.rs | 9 +- crates/fonts/src/fontdb.rs | 569 +++++++++++++++--------- crates/fonts/src/lib.rs | 2 +- crates/fonts/src/testing.rs | 10 +- crates/fonts/src/tests_docfonts.rs | 69 ++- crates/idml/src/import.rs | 1 + crates/render/src/glyphs.rs | 24 +- crates/ui-egui/src/dialogs.rs | 7 +- crates/ui-egui/src/panels/glyphs.rs | 7 +- crates/ui-egui/src/panels/mod.rs | 22 +- crates/ui-egui/src/panels/properties.rs | 9 +- crates/ui-egui/src/taskbar.rs | 7 +- docs/cjk.md | 2 +- 28 files changed, 753 insertions(+), 317 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9975367..2af25ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,7 +147,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML). - Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name). - File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files it uses in a Document Fonts folder (except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). -- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts stay loaded until DesignCraft quits, available to every open document; on the web there is no folder to read. +- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read. - Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index bb2303f..28b555b 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -240,7 +240,9 @@ impl Cache { } fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) { - let mut sig = vec![Arc::as_ptr(story) as usize, story.rev as usize, Arc::as_ptr(&doc.styles) as usize, doc.sections.len()]; + // The document's font scope: another document's fonts of the same name set it differently. + let mut sig = + vec![Arc::as_ptr(story) as usize, story.rev as usize, Arc::as_ptr(&doc.styles) as usize, doc.sections.len(), doc.font_scope as usize]; let mut keep = Vec::new(); let mut spreads = Vec::new(); for f in &story.frames { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8f7685f..062a692 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -30,7 +30,7 @@ use designcraft_doc::{ Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions, VerticalJustification, WrapMode, story, }; -use designcraft_fonts::FontDb; +use designcraft_fonts::{FontDb, ScopedFonts}; use designcraft_geom::{Point, Rect}; use crate::breaker::{Break, Spacing}; @@ -443,6 +443,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo } fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &ComposeOptions, db: &FontDb) -> ComposedStory { + let db = &db.scoped(doc.font_scope); let mut out = ComposedStory { story: story.id, rev: story.rev, text_len: story.text.len(), ..Default::default() }; let mut styles_tab: Vec = Vec::new(); let mut missing_fonts: HashMap = HashMap::new(); @@ -843,7 +844,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let last = k + 1 == breaks.len(); let (mut placed, end_x, ratio) = layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page, &bidi_info); - ruby::annotate(&styles_tab, &mut placed); + ruby::annotate(db, &styles_tab, &mut placed); let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); let range_start = if line_no == 0 { prange.start } else { range_start }; @@ -1427,13 +1428,12 @@ fn line_metrics( s: usize, base_leading: f64, base_size: f64, - db: &FontDb, + db: &ScopedFonts<'_>, base: &designcraft_doc::CharProps, ) -> (f64, f64, f64) { - let _ = db; let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line }; if src.is_empty() { - let face = FontDb::global().face(&base.font_family, &base.font_style); + let face = db.face(&base.font_family, &base.font_style); let k = base_size / face.upem; return (face.ascent * k, face.descent * k, base_leading); } @@ -1986,7 +1986,7 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph { } fn prepend_label( - db: &FontDb, + db: &ScopedFonts<'_>, glyphs: &mut Vec, label: &str, at: usize, diff --git a/crates/compose/src/ruby.rs b/crates/compose/src/ruby.rs index 06d57ba..c909e06 100644 --- a/crates/compose/src/ruby.rs +++ b/crates/compose/src/ruby.rs @@ -1,6 +1,7 @@ //! Ruby and kenten: small glyphs set over laid-out text (to its right in vertical frames, where //! the frame's turn carries "above" there). They don't change line breaks or leading. +use designcraft_fonts::ScopedFonts; use designcraft_geom::Shape as _; use crate::{PlacedGlyph, RunStyle, upright_in_vertical}; @@ -40,12 +41,12 @@ fn set(base: &PlacedGlyph, text: &str, size: f64, base_size: f64) -> (Vec Vec { +fn kenten(db: &ScopedFonts<'_>, b: &PlacedGlyph, size: f64, character: &str) -> Vec { let character = if character.is_empty() { "\u{FE45}" } else { character }; let mut base = b.clone(); if let Some(c) = character.chars().next() && !base.face.covers(c) - && let Some(face) = designcraft_fonts::FontDb::global().fallback_for(c, base.face.id(), None) + && let Some(face) = db.fallback_for(c, base.face.id(), None) { base.face = designcraft_fonts::FaceRef::of(&face); } @@ -68,7 +69,7 @@ fn kenten(b: &PlacedGlyph, size: f64, character: &str) -> Vec { } /// Add the ruby and kenten of a laid-out line's glyphs. -pub(crate) fn annotate(styles: &[RunStyle], line: &mut Vec) { +pub(crate) fn annotate(db: &ScopedFonts<'_>, styles: &[RunStyle], line: &mut Vec) { let style = |g: &PlacedGlyph| styles.get(g.style as usize); if !line.iter().any(|g| style(g).is_some_and(|s| s.ruby.is_some() || s.kenten)) { return; @@ -82,7 +83,7 @@ pub(crate) fn annotate(styles: &[RunStyle], line: &mut Vec) { continue; }; if st.kenten && g.adv > 0.0 { - extra.extend(kenten(g, st.size, &st.kenten_character)); + extra.extend(kenten(db, g, st.size, &st.kenten_character)); } let Some(text) = &st.ruby else { i += 1; @@ -114,7 +115,7 @@ pub(crate) fn annotate(styles: &[RunStyle], line: &mut Vec) { extra.extend(glyphs); // Kenten on the rest of the group. for b in line[i + 1..j].iter().filter(|b| st.kenten && b.len > 0 && b.adv > 0.0) { - extra.extend(kenten(b, st.size, &st.kenten_character)); + extra.extend(kenten(db, b, st.size, &st.kenten_character)); } i = j; } diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 8bd1bc9..2f46a9d 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::sync::{Arc, RwLock}; use designcraft_doc::{Capitalization, CharProps, Kerning, Leading, Position, Story, Styles, story}; -use designcraft_fonts::{FaceRef, Feature, FontDb, FontFace, ShapedGlyph, feature}; +use designcraft_fonts::{FaceRef, Feature, FontFace, ScopedFonts, ShapedGlyph, feature}; use crate::RunStyle; @@ -131,7 +131,7 @@ pub(crate) struct StyleTable<'a> { } impl StyleTable<'_> { - fn intern(&mut self, p: &CharProps) -> u32 { + fn intern(&mut self, db: &ScopedFonts<'_>, p: &CharProps) -> u32 { let rs = RunStyle { fill: p.fill.clone(), fill_tint: p.fill_tint, @@ -154,10 +154,7 @@ impl StyleTable<'_> { }, skew: p.skew, size: p.size, - missing_font: *self - .missing - .entry(p.font_family.clone()) - .or_insert_with(|| !p.font_family.is_empty() && !designcraft_fonts::FontDb::global().has_family(&p.font_family)), + missing_font: *self.missing.entry(p.font_family.clone()).or_insert_with(|| !p.font_family.is_empty() && !db.has_family(&p.font_family)), custom_tracking: p.tracking.abs() > 1e-9 || matches!(p.kerning, designcraft_doc::Kerning::Manual(_)), condition: p.conditions.first().cloned(), inserted: p.change == designcraft_doc::ChangeMark::Inserted, @@ -176,7 +173,7 @@ impl StyleTable<'_> { /// Resolve and shape one paragraph. pub(crate) fn shape_para( - db: &FontDb, + db: &ScopedFonts<'_>, styles: &Styles, story: &Story, pi: usize, @@ -237,18 +234,18 @@ pub(crate) fn shape_para( for (offset, c) in story.text.get(a..b).unwrap_or("").char_indices() { let entry = font.entry(c); if entry != selected && a + offset > start { - resolved.push((start, a + offset, composite_props(&props, selected), fmt, para_chars.clone())); + resolved.push((start, a + offset, composite_props(db, &props, selected), fmt, para_chars.clone())); start = a + offset; } selected = entry; } - resolved.push((start, b, composite_props(&props, selected), fmt, para_chars.clone())); + resolved.push((start, b, composite_props(db, &props, selected), fmt, para_chars.clone())); } else { resolved.push((a, b, props, fmt, para_chars.clone())); } } for (a, b, props, fmt, resolved_base) in resolved { - let style = table.intern(&props); + let style = table.intern(db, &props); let deleted = props.change == designcraft_doc::ChangeMark::Deleted; if deleted || (!props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c))) { // Hidden conditional text: zero-width, unbreakable, undrawn place-holders keep every @@ -278,11 +275,11 @@ pub(crate) fn shape_para( } rf.over.position = Some(sub.note_position); let rprops = styles.resolve_char(&resolved_base, &rf); - let rstyle = table.intern(&rprops); + let rstyle = table.intern(db, &rprops); let mut ef = fmt.clone(); ef.over.position = Some(designcraft_doc::Position::Superscript); let eprops = styles.resolve_char(&resolved_base, &ef); - let estyle = table.intern(&eprops); + let estyle = table.intern(db, &eprops); let mut k = a; for (i, m) in story.text[a..b].match_indices(is_ref) { let i = a + i; @@ -395,7 +392,7 @@ fn is_mark(c: char) -> bool { unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM || matches!(c, '\u{200C}' | '\u{200D}') } -fn composite_props(base: &CharProps, entry: Option<&designcraft_doc::cjk::CompositeFontEntry>) -> CharProps { +fn composite_props(db: &ScopedFonts<'_>, base: &CharProps, entry: Option<&designcraft_doc::cjk::CompositeFontEntry>) -> CharProps { let mut p = base.clone(); if let Some(e) = entry { p.font_family.clone_from(&e.family); @@ -405,7 +402,7 @@ fn composite_props(base: &CharProps, entry: Option<&designcraft_doc::cjk::Compos p.v_scale *= e.vertical_scale.clamp(0.01, 100.0); p.baseline_shift += base.size * e.baseline_shift; if e.scale_option { - let face = FontDb::global().face(&p.font_family, &p.font_style); + let face = db.face(&p.font_family, &p.font_style); let (asc, desc) = face.vertical_metrics(); let center = (asc - desc) / (2.0 * (asc + desc).max(1e-9)); p.baseline_shift += (base.size * base.v_scale - p.size * p.v_scale) * center; @@ -415,7 +412,16 @@ fn composite_props(base: &CharProps, entry: Option<&designcraft_doc::cjk::Compos } #[allow(clippy::too_many_arguments)] -fn shape_run(db: &FontDb, text: &str, range: std::ops::Range, p: &CharProps, env: TypeEnv, style: u32, sub: &SubstCtx, out: &mut Vec) { +fn shape_run( + db: &ScopedFonts<'_>, + text: &str, + range: std::ops::Range, + p: &CharProps, + env: TypeEnv, + style: u32, + sub: &SubstCtx, + out: &mut Vec, +) { let start = out.len(); shape_run_raw(db, text, range, p, env, style, sub, out); let run = &mut out[start..]; @@ -462,7 +468,7 @@ fn shape_run(db: &FontDb, text: &str, range: std::ops::Range, p: &CharPro #[allow(clippy::too_many_arguments)] fn shape_run_raw( - db: &FontDb, + db: &ScopedFonts<'_>, text: &str, range: std::ops::Range, p: &CharProps, @@ -774,7 +780,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty #[allow(clippy::too_many_arguments)] fn shape_segment( - db: &FontDb, + db: &ScopedFonts<'_>, text: &str, range: std::ops::Range, replacement: Option<&str>, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 07ea456..10deca3 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1506,6 +1506,66 @@ fn cjk_fallback_follows_the_language() { assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone())); } +#[test] +fn each_document_shapes_words_in_its_own_fonts() { + use designcraft_fonts::testing::font_with_glyph; + const FAMILY: &str = "DocFont Word Cache"; + // Two documents' fonts of one name: the same words, drawn as `X` in one and `O` in the other. + let root = std::env::temp_dir().join(format!("dc-compose-docfonts-{}", std::process::id())); + let mut scopes = Vec::new(); + for (name, glyph) in [("x", 'X'), ("o", 'O')] { + let folder = root.join(name); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("word.ttf"), font_with_glyph(FAMILY, &['a', 'b', ' '], glyph).unwrap()).unwrap(); + scopes.push((designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope, glyph)); + } + let text = "ab ab ab ab"; + let mut faces = Vec::new(); + for (scope, glyph) in scopes { + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default()); + d.font_scope = scope; + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let glyphs: Vec<&PlacedGlyph> = + all_lines(&cs).into_iter().flat_map(|l| l.glyphs.iter()).filter(|g| text[g.byte..].starts_with('a')).collect(); + assert_eq!(glyphs.len(), 4); + let face = glyphs[0].face; + let drawn = designcraft_fonts::FontDb::global().face(designcraft_fonts::FALLBACK_FAMILY, "Regular").glyph_for(glyph); + assert!(glyphs.iter().all(|g| g.face == face && g.gid == drawn), "scope {scope}: {glyph}"); + faces.push(face); + } + assert!(faces[0] != faces[1] && faces.iter().all(|f| f.family == FAMILY)); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn composite_fonts_draw_from_the_documents_fonts() { + use designcraft_doc::cjk::{CompositeFont, CompositeFontEntry}; + use designcraft_fonts::testing::font_with_glyph; + const FAMILY: &str = "DocFont Composite Member"; + // A composite font whose digits come from a font only the document has. + let folder = std::env::temp_dir().join(format!("dc-compose-composite-docfont-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("digits.ttf"), font_with_glyph(FAMILY, &['1'], 'X').unwrap()).unwrap(); + let scope = FontDb::global().load_document_fonts(&folder).scope; + let (mut d, sid, _) = doc_with("A1B", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + d.font_scope = scope; + std::sync::Arc::make_mut(&mut d.styles).composite_fonts.push(CompositeFont { + name: "Mixed".into(), + entries: vec![ + CompositeFontEntry { family: designcraft_fonts::DEFAULT_FAMILY.into(), ..Default::default() }, + CompositeFontEntry { characters: "0123456789".into(), family: FAMILY.into(), ..Default::default() }, + ], + }); + d.story_mut(sid).unwrap().format_chars(0..3, |f| f.over.font_family = Some("CompositeFont/Mixed".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let digit = cs.frames[0].lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap(); + assert_eq!(digit.face.family, FAMILY); + assert_ne!(digit.gid, 0); + assert!(!cs.styles.iter().any(|s| s.missing_font), "the document's font isn't missing"); + let _ = std::fs::remove_dir_all(&folder); +} + const KO_WORDS: &str = "한국어 문장은 띄어쓰기 단위로 줄을 바꿉니다 한국어 문장은 띄어쓰기 단위로 줄을 바꿉니다"; const KO_HANJA: &str = "大韓民國 憲法은 國民의 權利를 保障한다 大韓民國 憲法은 國民의 權利를 保障한다"; const KO_PUNCT: &str = "가나다라마。바사아자차」카타파하가、나다라마바。사아자차카」타파하가나、다라마바사。아자차카타」파하가나다、"; diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index ea58043..92fbee7 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -156,6 +156,7 @@ impl Document { created: crate::vars::now(), modified: 0, next_id: 0, + font_scope: 0, }; let lid = LayerId(d.alloc()); d.layers.push(Layer { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c76e057..8f80a78 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -515,6 +515,11 @@ pub struct Document { #[serde(default)] pub modified: i64, pub next_id: u64, + /// While the document is open, the font scope of the fonts it brought (its `Document Fonts` + /// folder): composition, export and the font menus look its fonts up there. 0: none. Not + /// saved. + #[serde(skip)] + pub font_scope: u32, } /// What a hyperlink is attached to. diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 0d2f5eb..9631dac 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -44,8 +44,6 @@ fn load(path: &str) -> Result { let _ = path; vec![] }; - // Skipped font files are logged; a chapter opens without them. - let _ = super::file::load_document_fonts(path); if path.to_lowercase().ends_with(".idml") { return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); } @@ -198,7 +196,9 @@ pub fn specs() -> Vec { let b = s.book.clone().ok_or_else(no_book)?; let mut parts = Vec::new(); for path in &b.documents { - let d = load(path)?; + let mut d = load(path)?; + // The chapter's own fonts, for this export (skipped font files are logged). + let _fonts = super::file::load_document_fonts(&mut d, path).0; let cache = designcraft_compose::Cache::new(); let r = designcraft_pdf::export_pdf_with_report(&d, &cache, &designcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?; let n = designcraft_render::pdf_page_count(&r.bytes).unwrap_or(0); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 116fa0e..71340ec 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -160,9 +160,11 @@ fn file_open(s: &mut Session, p: &Value) -> Result { return super::interchange::open_idml(s, p); } let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - let d = from_bytes(&bytes)?; - let (faces, warnings) = load_document_fonts(path); - let i = s.add_document(DocState::new(d, Some(path.to_string()))); + let mut d = from_bytes(&bytes)?; + let (fonts, faces, warnings) = load_document_fonts(&mut d, path); + let mut st = DocState::new(d, Some(path.to_string())); + st.fonts = fonts; + let i = s.add_document(st); Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) } #[cfg(target_arch = "wasm32")] @@ -172,20 +174,23 @@ fn file_open(s: &mut Session, p: &Value) -> Result { } } -/// Load the fonts in the `Document Fonts` folder beside the document file at `path` → (faces -/// loaded, a warning for each font file skipped). Nothing on the web (no folders). -pub(crate) fn load_document_fonts(path: &str) -> (usize, Vec) { +/// Load the fonts in the `Document Fonts` folder beside the document file at `path` for `d` alone +/// (`d.font_scope`) → (the scope, for the document's state to hold; faces; a warning for each +/// font file skipped). Nothing on the web (no folders). +pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option>, usize, Vec) { #[cfg(not(target_arch = "wasm32"))] { use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - let Some(folder) = std::path::Path::new(path).parent().map(|d| d.join(DOCUMENT_FONTS_FOLDER)) else { return (0, Vec::new()) }; + let Some(folder) = std::path::Path::new(path).parent().map(|d| d.join(DOCUMENT_FONTS_FOLDER)) else { return (None, 0, Vec::new()) }; let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder); - (r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect()) + d.font_scope = r.scope; + let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope))); + (scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect()) } #[cfg(target_arch = "wasm32")] { - let _ = path; - (0, Vec::new()) + let _ = (d, path); + (None, 0, Vec::new()) } } @@ -597,15 +602,18 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; #[cfg(not(target_arch = "wasm32"))] { - let d = if path.to_ascii_lowercase().ends_with(".idml") { + let mut d = if path.to_ascii_lowercase().ends_with(".idml") { let bytes = std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))? } else { from_bytes(&std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?)? }; + // As opened again: the fonts beside it now (unchanged files are shared, not read again). + let (fonts, _, _) = load_document_fonts(&mut d, &path); let uid = s.doc()?.uid; let st = s.doc_mut()?; let mut fresh = DocState::new(d, Some(path.clone())); + fresh.fonts = fonts; fresh.uid = uid; fresh.revision = st.revision + 1; *st = fresh; @@ -682,7 +690,7 @@ mod document_fonts_tests { use std::path::{Path, PathBuf}; use designcraft_fonts::testing::font_with; - use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FontDb}; + use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FaceRef, FontDb, FontSource}; use serde_json::{Value, json}; use crate::Session; @@ -694,13 +702,22 @@ mod document_fonts_tests { dir } - /// A document whose text is set in `family`, written to `path` (`.idml`: as IDML). - fn write_document(path: &Path, family: &str) { - let mut s = Session::new(); + /// A new document in `s` with a text frame holding `text` set in `family`. + fn new_document(s: &mut Session, family: &str, text: &str) { s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 5})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); + } + + /// A document whose text ("Hello") is set in `family`, written to `path` (`.idml`: as IDML). + fn write_document(path: &Path, family: &str) { + write_document_with(path, family, "Hello"); + } + + fn write_document_with(path: &Path, family: &str, text: &str) { + let mut s = Session::new(); + new_document(&mut s, family, text); if path.extension().is_some_and(|e| e == "idml") { std::fs::write(path, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); } else { @@ -713,6 +730,23 @@ mod document_fonts_tests { l.as_array().unwrap().iter().find(|f| f["family"] == family).cloned().unwrap_or_else(|| panic!("{family} in {l}")) } + /// Each character of the active document's text with the face it is drawn in. + fn glyph_faces(s: &Session) -> Vec<(char, FaceRef)> { + let st = s.doc().unwrap(); + let mut out = Vec::new(); + for (sid, story) in &st.doc.stories { + let cs = s.cache.get(&st.doc, *sid, None); + for g in cs.frames.iter().flat_map(|ft| &ft.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0) { + out.push((story.text[g.byte..].chars().next().unwrap(), g.face)); + } + } + out + } + + fn open(s: &mut Session, path: &Path) -> Value { + s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap() + } + #[test] fn opening_a_document_loads_its_document_fonts() { const FAMILY: &str = "DocFont Open Test"; @@ -759,4 +793,96 @@ mod document_fonts_tests { assert_eq!((r["documentFonts"].clone(), r["warnings"].clone()), (json!(0), json!([])), "{r}"); let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn document_fonts_belong_to_their_document() { + const FAMILY: &str = "DocFont Scoped"; + let (a, b) = (temp_dir("scoped-a"), temp_dir("scoped-b")); + std::fs::write(a.join(DOCUMENT_FONTS_FOLDER).join("scoped.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + write_document(&a.join("A.designcraft"), FAMILY); + write_document(&b.join("B.designcraft"), FAMILY); + + let mut s = Session::new(); + assert_eq!(open(&mut s, &a.join("A.designcraft"))["documentFonts"], 1); + assert_eq!(open(&mut s, &b.join("B.designcraft"))["documentFonts"], 0); + // B, open at the same time, doesn't have A's font. + assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY), "{:?}", glyph_faces(&s)); + s.execute("file.activate", &json!({"index": 0})).unwrap(); + assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY), "{:?}", glyph_faces(&s)); + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn each_document_sets_its_own_font_of_a_name() { + const FAMILY: &str = "DocFont Same"; + let (a, b) = (temp_dir("same-a"), temp_dir("same-b")); + let (a_font, b_font) = (a.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"), b.join(DOCUMENT_FONTS_FOLDER).join("same.ttf")); + // A's font has both letters, B's only `b`. + std::fs::write(&a_font, font_with(FAMILY, &['a', 'b']).unwrap()).unwrap(); + std::fs::write(&b_font, font_with(FAMILY, &['b']).unwrap()).unwrap(); + // The same words in both: the word cache must not hand one document's shaping to the other. + write_document_with(&a.join("A.designcraft"), FAMILY, "ab ab ab"); + write_document_with(&b.join("B.designcraft"), FAMILY, "ab ab ab"); + + let mut s = Session::new(); + open(&mut s, &a.join("A.designcraft")); + open(&mut s, &b.join("B.designcraft")); + let in_b = glyph_faces(&s); + s.execute("file.activate", &json!({"index": 0})).unwrap(); + let in_a = glyph_faces(&s); + assert_eq!((in_a.len(), in_b.len()), (8, 8), "{in_a:?} {in_b:?}"); + for (c, f) in &in_a { + assert_eq!(f.source, FontSource::Document(a_font.clone()), "`{c}` in A"); + } + for (c, f) in &in_b { + match c { + 'a' => assert!(f.family != FAMILY, "B's font lacks `a`, and A's isn't B's: {f:?}"), + _ => assert_eq!(f.source, FontSource::Document(b_font.clone()), "`{c}` in B"), + } + } + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn closing_a_document_forgets_its_fonts_and_reopening_reuses_them() { + const FAMILY: &str = "DocFont Closed"; + let dir = temp_dir("closed"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("closed.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Closed.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + let first = glyph_faces(&s)[0].1; + assert_eq!(first.family, FAMILY); + s.execute("file.close", &json!({})).unwrap(); + // A new document can't use the closed document's font. + new_document(&mut s, FAMILY, "Hello"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY)); + // Reopened, the document has its font again: the face loaded the first time. + assert_eq!(open(&mut s, &path)["documentFonts"], 1); + assert!(glyph_faces(&s).iter().all(|(_, f)| *f == first), "{:?}", glyph_faces(&s)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_reverted_document_keeps_its_fonts() { + const FAMILY: &str = "DocFont Reverted"; + let dir = temp_dir("revert"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("revert.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Revert.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + s.execute("frame.create", &json!({"rect": [72, 400, 200, 500], "content": "text", "text": "x"})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY)); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 65c6623..ef7eb93 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -44,7 +44,7 @@ fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) { } fn list(d: &Document) -> Vec { - let db = designcraft_fonts::FontDb::global(); + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let mut used: BTreeMap<(String, String), usize> = BTreeMap::new(); for_each_story(d, &mut |st| { let ranges = st.para_ranges(); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 519bd24..6d16417 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -71,11 +71,13 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { if let Some(n) = name { d.title = n; } - let (faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) { - Some(path) => super::file::load_document_fonts(path), - None => (0, Vec::new()), + let (fonts, faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) { + Some(path) => super::file::load_document_fonts(&mut d, path), + None => (None, 0, Vec::new()), }; // Never save over the .idml with the native format: the document starts unsaved. - let i = s.add_document(DocState::new(d, None)); + let mut st = DocState::new(d, None); + st.fonts = fonts; + let i = s.add_document(st); Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) } diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index a324f3b..044c3f9 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -123,7 +123,7 @@ fn package(s: &mut Session, p: &Value) -> Result { std::fs::write(&doc_path, super::to_bytes(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", doc_path.display())))?; files.insert(0, doc_path); // The font files, and what the report says about each font. - let db = designcraft_fonts::FontDb::global(); + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let (fonts_dir, mut copied, mut taken) = (dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER), Vec::new(), Vec::new()); let mut font_lines = Vec::new(); for f in fonts.as_array().into_iter().flatten() { diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 76e0b37..b8c345d 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -37,7 +37,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { let d = &st.doc; let mut out = Vec::new(); // Stories: overset and fonts. - let db = designcraft_fonts::FontDb::global(); + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let mut missing_fonts: Vec = Vec::new(); let mut unsupported_typography = std::collections::BTreeSet::new(); for story in d.stories.values() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 03562bb..1512ce3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -91,6 +91,20 @@ pub struct DocState { /// Editing parent spreads (Pages panel double-click on a parent). pub editing_parents: bool, pub uid: u64, + /// The fonts the document brought (`doc.font_scope`), found while it is open. + pub fonts: Option>, +} + +/// A document's font scope ([`designcraft_fonts::FontDb::load_document_fonts`]): its fonts are +/// found until the document's state (with every copy of it) is gone — closed, reverted, or its +/// session ended. +#[derive(Debug)] +pub struct FontScope(pub u32); + +impl Drop for FontScope { + fn drop(&mut self) { + designcraft_fonts::FontDb::global().close_scope(self.0); + } } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -111,6 +125,7 @@ impl DocState { interaction: None, editing_parents: false, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + fonts: None, } } pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 3c6bf86..09c9654 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -86,16 +86,15 @@ pub fn open(s: &mut Session, dir: &Path) -> Result> { for (uid, meta) in list(dir) { let (doc, _) = files(dir, uid); let Ok(bytes) = std::fs::read(&doc) else { continue }; - let d = match crate::cmd::from_bytes(&bytes) { + let mut d = match crate::cmd::from_bytes(&bytes) { Ok(d) => d, Err(_) => continue, }; let path = meta.get("path").and_then(Value::as_str).map(str::to_string); - if let Some(p) = &path { - // Skipped font files are logged; the document opens without them. - let _ = crate::cmd::load_document_fonts(p); - } + // Skipped font files are logged; the document opens without them. + let fonts = path.as_deref().and_then(|p| crate::cmd::load_document_fonts(&mut d, p).0); let mut st = DocState::new(d, path); + st.fonts = fonts; // Unsaved: the copy on disk (if any) is older than what was recovered. st.saved_doc = std::sync::Arc::new((*st.doc).clone()); st.revision += 1; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 0ba6531..a1461db 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -75,9 +75,11 @@ pub const MAX_DOCUMENT_FONT_FILES: usize = 200; pub const MAX_DOCUMENT_FONT_BYTES: u64 = 256 << 20; pub const MAX_DOCUMENT_FONTS_TOTAL: u64 = 512 << 20; -/// What [`FontDb::load_document_fonts`] did: faces added, and a note for each file it skipped. +/// What [`FontDb::load_document_fonts`] did: the scope the document looks its fonts up in (0 when +/// the folder gave none), the faces in it, and a note for each file skipped. #[derive(Debug, Default)] pub struct DocumentFonts { + pub scope: u32, pub faces: usize, pub skipped: Vec, } @@ -291,9 +293,18 @@ struct CatalogEntry { path: std::path::PathBuf, } -/// Process-wide font database. +/// Process-wide font database: the fonts every document shares (bundled, installed, added), and +/// each open document's own fonts under its scope (see [`FontDb::scoped`]). pub struct FontDb { faces: RwLock>>, + /// Each open document's fonts (its `Document Fonts` folder), by scope. + scopes: RwLock]>>>, + /// The document font files read, by path: (length, modification time, faces). A document + /// opened again finds its unchanged files here instead of reading them again. + #[cfg(not(target_arch = "wasm32"))] + document_files: Mutex, Vec>)>>, + /// Variable font instances (`Style {wght:650}`), made on first use, by base face and style. + instances: RwLock>>, outlines: Mutex>>, #[cfg(not(target_arch = "wasm32"))] catalog: RwLock>, @@ -451,6 +462,9 @@ fn cjk_chain(c: char, language: Option<&str>) -> Option<&'static [&'static str]> } static NEXT_ID: AtomicU32 = AtomicU32::new(1); +/// Scopes are never reused: a closed document's scope finds no fonts ever after. +#[cfg(not(target_arch = "wasm32"))] +static NEXT_SCOPE: AtomicU32 = AtomicU32::new(1); const OUTLINE_CACHE_MAX: usize = 50_000; fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { @@ -458,6 +472,11 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { } /// A face's family and style names (the default instance's style for a variable font). +/// Is a face of `family` and `style` among `faces`? +fn has_face(faces: &[Arc], family: &str, style: &str) -> bool { + faces.iter().any(|f| f.family.eq_ignore_ascii_case(family) && f.style.eq_ignore_ascii_case(style)) +} + fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); @@ -698,6 +717,10 @@ impl FontDb { } Self { faces: RwLock::new(faces), + scopes: RwLock::new(HashMap::new()), + #[cfg(not(target_arch = "wasm32"))] + document_files: Mutex::new(HashMap::new()), + instances: RwLock::new(HashMap::new()), outlines: Mutex::new(HashMap::new()), #[cfg(not(target_arch = "wasm32"))] catalog: RwLock::new(Vec::new()), @@ -739,33 +762,21 @@ impl FontDb { self.catalog.read().unwrap_or_else(|e| e.into_inner()) } - /// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. - pub fn families(&self) -> Vec { - let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).collect(); - #[cfg(not(target_arch = "wasm32"))] - v.extend(self.read_catalog().iter().map(|c| c.family.clone())); - v.sort_by_key(|a| a.to_lowercase()); - v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - v + /// What the document with font scope `scope` sees: its own fonts ahead of the shared ones. + /// Scope 0 (a document without fonts of its own) or a closed scope sees the shared fonts. + pub fn scoped(&self, scope: u32) -> ScopedFonts<'_> { + let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(|e| e.into_inner()).get(&scope).cloned() }; + ScopedFonts { db: self, own } } - /// Style names available for `family` (Regular first, then by weight). + /// The shared fonts' family names (see [`ScopedFonts::families`]). + pub fn families(&self) -> Vec { + self.scoped(0).families() + } + + /// The shared fonts' styles of `family` (see [`ScopedFonts::styles`]). pub fn styles(&self, family: &str) -> Vec { - let mut v: Vec<(bool, f32, String)> = self - .read_faces() - .iter() - .filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{')) - .map(|f| (f.italic, f.weight, f.style.clone())) - .collect(); - #[cfg(not(target_arch = "wasm32"))] - for c in self.read_catalog().iter() { - if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { - v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); - } - } - v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); - v.dedup_by(|a, b| a.2 == b.2); - v.into_iter().map(|t| t.2).collect() + self.scoped(0).styles(family) } /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or @@ -774,28 +785,32 @@ impl FontDb { self.add_faces(bytes, FontSource::Memory) } - /// Add the faces of a font file from `source`. A document's font is skipped only when the same - /// folder already gave that family and style (it stands in for other fonts of the name); any - /// other is skipped when a face of that family and style is loaded. + /// Add the faces of a font file from `source` to the shared fonts, skipping the family and + /// style already loaded. fn add_faces(&self, bytes: Vec, source: FontSource) -> usize { + let faces = self.new_faces(bytes, source); + self.insert_faces(faces) + } + + /// The faces of a font file from `source` whose family and style aren't loaded yet. + fn new_faces(&self, bytes: Vec, source: FontSource) -> Vec { let data = Arc::new(bytes); - let folder = |s: &FontSource| match s { - FontSource::Document(p) => p.parent().map(std::path::Path::to_path_buf), - _ => None, - }; - let doc_folder = folder(&source); + enumerate_faces(&data) + .into_iter() + .filter(|(_, family, style, _)| !has_face(&self.read_faces(), family, style)) + .filter_map(|(i, family, style, coords)| make_face(FontBytes::Owned(data.clone()), source.clone(), i, family, style, coords)) + .collect() + } + + /// Add `faces` to the shared fonts all at once, so a lookup sees all of them or none (not a + /// family's bold without its regular), skipping a family and style another thread has added + /// meanwhile. Returns the number added. + fn insert_faces(&self, new: Vec) -> usize { + let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner()); let mut added = 0; - for (i, family, style, coords) in enumerate_faces(&data) { - let same = |f: &&Arc| { - f.family.eq_ignore_ascii_case(&family) - && f.style.eq_ignore_ascii_case(&style) - && (doc_folder.is_none() || folder(&f.source) == doc_folder) - }; - if self.read_faces().iter().any(|f| same(&f)) { - continue; - } - if let Some(f) = make_face(FontBytes::Owned(data.clone()), source.clone(), i, family, style, coords) { - self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f)); + for f in new { + if !has_face(&faces, &f.family, &f.style) { + faces.push(Arc::new(f)); added += 1; } } @@ -875,24 +890,26 @@ impl FontDb { p.dedup(); p }; - let mut any = false; + // The whole family arrives at once (see `insert_faces`). + let mut faces = Vec::new(); for p in paths { if let Ok(data) = std::fs::read(&p) { - any |= self.add_faces(data, FontSource::Installed(p)) > 0; + faces.extend(self.new_faces(data, FontSource::Installed(p))); } } - any + self.insert_faces(faces) > 0 } /// Load the font files (.ttf, .otf, .ttc, .otc; not in subfolders) of a document's `Document - /// Fonts` folder. Their faces come before other faces of the same family and style when a - /// family is looked up, and stay loaded. The files are untrusted: past + /// Fonts` folder under a new scope, for that document alone: looked up through + /// [`FontDb::scoped`], they come before the shared faces of the same family and style, until + /// [`FontDb::close_scope`]. A file loaded before and unchanged since (same length and + /// modification time) isn't read again: its faces are shared. The files are untrusted: past /// [`MAX_DOCUMENT_FONT_FILES`] (in name order), files over [`MAX_DOCUMENT_FONT_BYTES`] or past /// [`MAX_DOCUMENT_FONTS_TOTAL`] in all, and files that aren't fonts are skipped with a note. A - /// missing folder loads nothing. + /// missing folder loads nothing (scope 0). #[cfg(not(target_arch = "wasm32"))] pub fn load_document_fonts(&self, folder: &std::path::Path) -> DocumentFonts { - use std::io::Read; let mut out = DocumentFonts::default(); let Ok(entries) = std::fs::read_dir(folder) else { return out }; let mut files: Vec = entries @@ -905,197 +922,120 @@ impl FontDb { .collect(); files.sort(); let mut total = 0u64; + let mut own: Vec> = Vec::new(); for (n, path) in files.iter().enumerate() { if n == MAX_DOCUMENT_FONT_FILES { out.skipped.push(format!("{} more font files not loaded (at most {MAX_DOCUMENT_FONT_FILES} are)", files.len() - n)); break; } let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()); - let mut data = Vec::new(); - let too_large = format!("larger than {} MB", MAX_DOCUMENT_FONT_BYTES >> 20); - let over_total = format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20); - // The listed length skips oversized files unread; one byte past the limit while reading - // catches a file that grew since. - let listed = std::fs::metadata(path).map_or(0, |m| m.len()); - let read = if listed > MAX_DOCUMENT_FONT_BYTES || total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL { - Ok(0) + // The listed length skips oversized files unread. + let meta = std::fs::metadata(path).ok(); + let listed = meta.as_ref().map_or(0, |m| m.len()); + let faces = if listed > MAX_DOCUMENT_FONT_BYTES { + Err(too_large()) + } else if total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL { + Err(over_total()) } else { - std::fs::File::open(path).and_then(|f| f.take(MAX_DOCUMENT_FONT_BYTES + 1).read_to_end(&mut data)) + let modified = meta.and_then(|m| m.modified().ok()); + self.document_file(path, listed, modified, MAX_DOCUMENT_FONTS_TOTAL.saturating_sub(total)) }; - let reason = match read { - Err(e) => Some(format!("can't be read ({e})")), - Ok(_) if listed > MAX_DOCUMENT_FONT_BYTES => Some(too_large), - Ok(len) if len as u64 > MAX_DOCUMENT_FONT_BYTES => Some(too_large), - Ok(len) if total.saturating_add(listed.max(len as u64)) > MAX_DOCUMENT_FONTS_TOTAL => Some(over_total), - Ok(_) if enumerate_faces(&data).is_empty() => Some("not a font DesignCraft can read".into()), - Ok(len) => { - total = total.saturating_add(len as u64); - None + match faces { + Err(r) => out.skipped.push(format!("{name}: {r}")), + Ok((faces, len)) => { + total = total.saturating_add(len); + // A family and style the folder already gave stays with its first file. + for f in faces { + if !own.iter().any(|o| o.family.eq_ignore_ascii_case(&f.family) && o.style.eq_ignore_ascii_case(&f.style)) { + own.push(f); + } + } } - }; - match reason { - Some(r) => out.skipped.push(format!("{name}: {r}")), - None => out.faces += self.add_faces(data, FontSource::Document(path.clone())), } } for s in &out.skipped { log::warn!("{}: {s}", folder.display()); } + if !own.is_empty() { + out.scope = NEXT_SCOPE.fetch_add(1, Ordering::Relaxed); + out.faces = own.len(); + self.scopes.write().unwrap_or_else(|e| e.into_inner()).insert(out.scope, own.into()); + } out } - /// Resolve a family + style to a face, falling back to the closest style of the family, then to - /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. - pub fn face(&self, family: &str, style: &str) -> Arc { - if let Some(f) = self.find(family, style) { - return f; - } - #[cfg(not(target_arch = "wasm32"))] - if self.load_cataloged(family) - && let Some(f) = self.find(family, style) + /// The faces of the document font file at `path` (`listed` bytes long, modified at + /// `modified`): those read before if it hasn't changed since, else read now, within `room` + /// bytes of the folder's total → (faces, bytes), or why the file is skipped. + #[cfg(not(target_arch = "wasm32"))] + fn document_file( + &self, + path: &std::path::Path, + listed: u64, + modified: Option, + room: u64, + ) -> Result<(Vec>, u64), String> { + use std::io::Read; + if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(|e| e.into_inner()).get(path) + && (*len, *m) == (listed, modified) { - return f; + return Ok((faces.clone(), listed)); } - self.find(FALLBACK_FAMILY, style) - .or_else(|| self.find(FALLBACK_FAMILY, "Regular")) - .or_else(|| self.read_faces().first().cloned()) - .unwrap_or_else(last_resort_face) + // One byte past the limit catches a file that grew since it was listed. + let mut data = Vec::new(); + std::fs::File::open(path) + .and_then(|f| f.take(MAX_DOCUMENT_FONT_BYTES + 1).read_to_end(&mut data)) + .map_err(|e| format!("can't be read ({e})"))?; + let len = data.len() as u64; + if len > MAX_DOCUMENT_FONT_BYTES { + return Err(too_large()); + } + if listed.max(len) > room { + return Err(over_total()); + } + let data = Arc::new(data); + let faces: Vec> = enumerate_faces(&data) + .into_iter() + .filter_map(|(i, family, style, coords)| { + make_face(FontBytes::Owned(data.clone()), FontSource::Document(path.to_path_buf()), i, family, style, coords) + }) + .map(Arc::new) + .collect(); + if faces.is_empty() { + return Err("not a font DesignCraft can read".into()); + } + self.document_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), (listed, modified, faces.clone())); + Ok((faces, len)) } - /// Is `family` available (loaded, or installed on the system)? + /// Close a document's font scope: its fonts are no longer found (in it or anywhere). Their + /// files stay read, for the document opening again. + pub fn close_scope(&self, scope: u32) { + self.scopes.write().unwrap_or_else(|e| e.into_inner()).remove(&scope); + } + + /// The shared fonts' face for `family` + `style` (see [`ScopedFonts::face`]). + pub fn face(&self, family: &str, style: &str) -> Arc { + self.scoped(0).face(family, style) + } + + /// Is `family` among the shared fonts (loaded, or installed on the system)? pub fn has_family(&self, family: &str) -> bool { - if self.is_loaded(family) { - return true; - } - #[cfg(not(target_arch = "wasm32"))] - if self.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) { - return true; - } - false + self.scoped(0).has_family(family) } fn is_loaded(&self, family: &str) -> bool { self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) } - /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. + /// A shared variable font's axes (see [`ScopedFonts::axes`]). pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> { - let face = self.face(family, base_style(style)); - let Some(f) = face.skrifa() else { return vec![] }; - f.axes() - .iter() - .map(|a| { - let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string(); - let name = f.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone()); - (tag, name, a.min_value(), a.default_value(), a.max_value()) - }) - .collect() + self.scoped(0).axes(family, style) } - /// `Style {wght:650,wdth:90}`: the named style's font at those axis values, made on first use. - fn instance(&self, family: &str, style: &str) -> Option> { - let open = style.find('{')?; - let base = self.find(family, style[..open].trim())?; - if base.skrifa().is_none_or(|f| f.axes().is_empty()) { - return Some(base); - } - let mut coords = base.coords.clone(); - for kv in style[open + 1..].trim_end_matches('}').split(',') { - let (k, v) = kv.split_once(':')?; - let k = k.trim().as_bytes(); - let v: f32 = v.trim().parse().ok()?; - if k.len() != 4 { - return None; - } - let tag = [k[0], k[1], k[2], k[3]]; - match coords.iter_mut().find(|(t, _)| *t == tag) { - Some(c) => c.1 = v, - None => coords.push((tag, v)), - } - } - let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?; - let f = Arc::new(f); - self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone()); - Some(f) - } - - fn find(&self, family: &str, style: &str) -> Option> { - if style.contains('{') { - { - let faces = self.read_faces(); - if let Some(f) = faces.iter().find(|f| f.family.eq_ignore_ascii_case(family) && f.style == style) { - return Some(f.clone()); - } - } - return self.instance(family, style); - } - let faces = self.read_faces(); - // Document fonts first, the most recently loaded first: they stand in for other fonts of - // the same name. - let (mut cands, others): (Vec<&Arc>, Vec<&Arc>) = - faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).partition(|f| matches!(f.source, FontSource::Document(_))); - cands.reverse(); - cands.extend(others); - if cands.is_empty() { - return None; - } - let ns = norm(style); - if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { - return Some((*f).clone()); - } - let (tw, ti) = (style_weight(style), style_italic(style)); - cands - .iter() - .min_by(|a, b| { - let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 }; - let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 }; - sa.total_cmp(&sb) - }) - .map(|f| (*f).clone()) - } - - /// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when - /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the - /// first face (fallback family first, then load order) that covers `c`. On native, system - /// fonts are cataloged and loaded lazily the first time no loaded face covers a character. + /// A shared face other than `exclude` that covers `c` (see [`ScopedFonts::fallback_for`]). pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option> { - if let Some(chain) = cjk_chain(c, language) - && let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude)) - { - return Some(f); - } - if let Some(f) = self.loaded_fallback(c, exclude) { - return Some(f); - } - #[cfg(not(target_arch = "wasm32"))] - if self.system_fallback(c) { - return self.loaded_fallback(c, exclude); - } - None - } - - /// The face of `family` (regular first) that covers `c`, loading the installed family the - /// first time a chain asks for it (native, while the system fallback is on). - fn chain_face(&self, family: &'static str, c: char, exclude: u32) -> Option> { - #[cfg(not(target_arch = "wasm32"))] - if !self.is_loaded(family) { - // Held while loading, so a parallel lookup waits for the font instead of passing it by. - let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); - if sys.enabled && sys.chain_tried.insert(family) { - self.load_cataloged(family); - } - } - let faces = self.read_faces(); - let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(family)).collect(); - order.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32)); - order.into_iter().find(|f| f.covers(c)).cloned() - } - - fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { - let faces = self.read_faces(); - let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); - order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); - order.into_iter().find(|f| f.covers(c)).cloned() + self.scoped(0).fallback_for(c, exclude, language) } /// Load a system font covering `c` (preferred fallback families first, then any cataloged @@ -1161,6 +1101,213 @@ impl FontDb { } } +/// One document's view of the [`FontDb`] ([`FontDb::scoped`]): the fonts of its `Document Fonts` +/// folder, then the shared fonts (bundled, installed, added). Other documents' fonts aren't in it. +#[derive(Clone)] +pub struct ScopedFonts<'a> { + db: &'a FontDb, + /// The document's own faces, in the folder's file order (none without a scope). + own: Option]>>, +} + +impl ScopedFonts<'_> { + fn own(&self) -> &[Arc] { + self.own.as_deref().unwrap_or(&[]) + } + + /// Family names available (the document's, loaded and cataloged system fonts), sorted and + /// deduplicated. + pub fn families(&self) -> Vec { + let mut v: Vec = self.own().iter().chain(self.db.read_faces().iter()).map(|f| f.family.clone()).collect(); + #[cfg(not(target_arch = "wasm32"))] + v.extend(self.db.read_catalog().iter().map(|c| c.family.clone())); + v.sort_by_key(|a| a.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v + } + + /// Style names available for `family` (Regular first, then by weight). + pub fn styles(&self, family: &str) -> Vec { + let mut v: Vec<(bool, f32, String)> = self + .own() + .iter() + .chain(self.db.read_faces().iter()) + .filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{')) + .map(|f| (f.italic, f.weight, f.style.clone())) + .collect(); + #[cfg(not(target_arch = "wasm32"))] + for c in self.db.read_catalog().iter() { + if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { + v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); + } + } + v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); + v.dedup_by(|a, b| a.2 == b.2); + v.into_iter().map(|t| t.2).collect() + } + + /// Resolve a family + style to a face, falling back to the closest style of the family, then to + /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. + pub fn face(&self, family: &str, style: &str) -> Arc { + if let Some(f) = self.find(family, style) { + return f; + } + #[cfg(not(target_arch = "wasm32"))] + if self.db.load_cataloged(family) + && let Some(f) = self.find(family, style) + { + return f; + } + self.find(FALLBACK_FAMILY, style) + .or_else(|| self.find(FALLBACK_FAMILY, "Regular")) + .or_else(|| self.db.read_faces().first().cloned()) + .unwrap_or_else(last_resort_face) + } + + /// Is `family` available (the document's, loaded, or installed on the system)? + pub fn has_family(&self, family: &str) -> bool { + if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family)) || self.db.is_loaded(family) { + return true; + } + #[cfg(not(target_arch = "wasm32"))] + if self.db.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) { + return true; + } + false + } + + /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. + pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> { + let face = self.face(family, base_style(style)); + let Some(f) = face.skrifa() else { return vec![] }; + f.axes() + .iter() + .map(|a| { + let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string(); + let name = f.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone()); + (tag, name, a.min_value(), a.default_value(), a.max_value()) + }) + .collect() + } + + /// Glyph outline in font units, y-down (flipped), cached per (face, glyph). + pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { + self.db.outline(face, gid) + } + + /// `Style {wght:650,wdth:90}`: the named style's font at those axis values, made on first use. + fn instance(&self, family: &str, style: &str) -> Option> { + let open = style.find('{')?; + let base = self.find(family, style[..open].trim())?; + if base.skrifa().is_none_or(|f| f.axes().is_empty()) { + return Some(base); + } + let key = (base.id, style.to_string()); + if let Some(f) = self.db.instances.read().unwrap_or_else(|e| e.into_inner()).get(&key) { + return Some(f.clone()); + } + let mut coords = base.coords.clone(); + for kv in style[open + 1..].trim_end_matches('}').split(',') { + let (k, v) = kv.split_once(':')?; + let k = k.trim().as_bytes(); + let v: f32 = v.trim().parse().ok()?; + if k.len() != 4 { + return None; + } + let tag = [k[0], k[1], k[2], k[3]]; + match coords.iter_mut().find(|(t, _)| *t == tag) { + Some(c) => c.1 = v, + None => coords.push((tag, v)), + } + } + let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?; + Some(self.db.instances.write().unwrap_or_else(|e| e.into_inner()).entry(key).or_insert_with(|| Arc::new(f)).clone()) + } + + fn find(&self, family: &str, style: &str) -> Option> { + if style.contains('{') { + return self.instance(family, style); + } + let faces = self.db.read_faces(); + // The document's fonts first: they stand in for other fonts of the same name. + let named = |f: &&Arc| f.family.eq_ignore_ascii_case(family); + let cands: Vec<&Arc> = self.own().iter().filter(named).chain(faces.iter().filter(named)).collect(); + if cands.is_empty() { + return None; + } + let ns = norm(style); + if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { + return Some((*f).clone()); + } + let (tw, ti) = (style_weight(style), style_italic(style)); + cands + .iter() + .min_by(|a, b| { + let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 }; + let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 }; + sa.total_cmp(&sb) + }) + .map(|f| (*f).clone()) + } + + /// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when + /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the + /// first face (fallback family first, then load order, the document's fonts last) that covers + /// `c`. On native, system fonts are cataloged and loaded lazily the first time no loaded face + /// covers a character. + pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option> { + if let Some(chain) = cjk_chain(c, language) + && let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude)) + { + return Some(f); + } + if let Some(f) = self.loaded_fallback(c, exclude) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.db.system_fallback(c) { + return self.loaded_fallback(c, exclude); + } + None + } + + /// The face of `family` (the document's first, regular first) that covers `c`, loading the + /// installed family the first time a chain asks for it (native, while the system fallback is + /// on). + fn chain_face(&self, family: &'static str, c: char, exclude: u32) -> Option> { + #[cfg(not(target_arch = "wasm32"))] + if !self.db.is_loaded(family) { + // Held while loading, so a parallel lookup waits for the font instead of passing it by. + let mut sys = self.db.sys.lock().unwrap_or_else(|e| e.into_inner()); + if sys.enabled && sys.chain_tried.insert(family) { + self.db.load_cataloged(family); + } + } + let faces = self.db.read_faces(); + let mut order: Vec<&Arc> = + self.own().iter().chain(faces.iter()).filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(family)).collect(); + order.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32)); + order.into_iter().find(|f| f.covers(c)).cloned() + } + + fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { + let faces = self.db.read_faces(); + let mut order: Vec<&Arc> = faces.iter().chain(self.own().iter()).filter(|f| f.id != exclude).collect(); + order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); + order.into_iter().find(|f| f.covers(c)).cloned() + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn too_large() -> String { + format!("larger than {} MB", MAX_DOCUMENT_FONT_BYTES >> 20) +} + +#[cfg(not(target_arch = "wasm32"))] +fn over_total() -> String { + format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20) +} + struct FlipPen(BezPath); impl OutlinePen for FlipPen { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index bbce046..1957c5b 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -14,7 +14,7 @@ mod vertical; pub use fontdb::{ DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, - MAX_DOCUMENT_FONTS_TOTAL, base_style, bundled, system_font_dirs, + MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, system_font_dirs, }; pub use harfrust::Feature; use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer}; diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs index 3c6e437..beec1a7 100644 --- a/crates/fonts/src/testing.rs +++ b/crates/fonts/src/testing.rs @@ -2,12 +2,18 @@ use skrifa::MetadataProvider; -/// A font named `family` (style Regular) that maps exactly `chars`, all to one glyph: the bundled +/// A font named `family` (style Regular) that maps exactly `chars`, all to one glyph (`X`): the bundled /// Source Sans 3 Regular with its `name` and `cmap` tables replaced. Stands in for a font that /// isn't installed (a system CJK font on a machine without it). `None` only if the bundled font /// can't be read. pub fn font_with(family: &str, chars: &[char]) -> Option> { - let glyphs: Vec<(char, char)> = chars.iter().map(|c| (*c, 'X')).collect(); + font_with_glyph(family, chars, 'X') +} + +/// [`font_with`], drawing every character as Source Sans 3's `glyph` (two fonts of one name that +/// set the same text differently). +pub fn font_with_glyph(family: &str, chars: &[char], glyph: char) -> Option> { + let glyphs: Vec<(char, char)> = chars.iter().map(|c| (*c, glyph)).collect(); font_mapping(family, &glyphs) } diff --git a/crates/fonts/src/tests_docfonts.rs b/crates/fonts/src/tests_docfonts.rs index e1fb019..525bc50 100644 --- a/crates/fonts/src/tests_docfonts.rs +++ b/crates/fonts/src/tests_docfonts.rs @@ -1,5 +1,6 @@ -//! Document fonts: the fonts in a `Document Fonts` folder beside a document, loaded ahead of the -//! installed fonts of the same name; damaged, oversized and surplus files are skipped. +//! Document fonts: the fonts in a `Document Fonts` folder beside a document, loaded for that +//! document alone (its scope) ahead of the installed fonts of the same name; damaged, oversized +//! and surplus files are skipped. use std::path::{Path, PathBuf}; @@ -37,14 +38,17 @@ fn document_fonts_load_ahead_of_installed_ones() { let r = db.load_document_fonts(&folder); assert_eq!((r.faces, r.skipped.len()), (1, 0), "{:?}", r.skipped); - let face = db.face("DocFont Precedence", "Regular"); + let face = db.scoped(r.scope).face("DocFont Precedence", "Regular"); assert!(face.covers('b'), "the document's face wins"); assert_eq!(face.source, FontSource::Document(file.clone())); assert_eq!(face.source.path(), Some(file.as_path())); + assert!(db.face("DocFont Precedence", "Regular").covers('a'), "outside the document the installed face stays"); - // Loading the folder again adds nothing. + // Loading the folder again (another document beside it) shares the face, in a scope of its own. let again = db.load_document_fonts(&folder); - assert_eq!((again.faces, again.skipped.len()), (0, 0)); + assert_eq!((again.faces, again.skipped.len()), (1, 0)); + assert_ne!(again.scope, r.scope); + assert!(Arc::ptr_eq(&db.scoped(again.scope).face("DocFont Precedence", "Regular"), &face)); let _ = std::fs::remove_dir_all(&root); } @@ -62,8 +66,9 @@ fn damaged_oversized_and_other_files_are_skipped() { let db = FontDb::with_font_dirs(vec![]); let r = db.load_document_fonts(&folder); assert_eq!(r.faces, 1); - assert!(db.has_family("DocFont Good")); - assert!(!db.has_family("DocFont Nested"), "subfolders aren't read"); + assert!(db.scoped(r.scope).has_family("DocFont Good")); + assert!(!db.has_family("DocFont Good"), "the document's alone"); + assert!(!db.scoped(r.scope).has_family("DocFont Nested"), "subfolders aren't read"); assert_eq!(r.skipped.len(), 2, "{:?}", r.skipped); assert!(r.skipped.iter().any(|s| s.contains("broken.ttf")), "{:?}", r.skipped); assert!(r.skipped.iter().any(|s| s.contains("huge.ttc") && s.contains(&format!("{} MB", MAX_DOCUMENT_FONT_BYTES >> 20))), "{:?}", r.skipped); @@ -81,8 +86,8 @@ fn the_number_of_files_read_is_capped() { let db = FontDb::with_font_dirs(vec![]); let r = db.load_document_fonts(&folder); - assert_eq!(r.faces, 0); - assert!(!db.has_family("DocFont Past Cap")); + assert_eq!((r.faces, r.scope), (0, 0)); + assert!(!db.scoped(r.scope).has_family("DocFont Past Cap")); assert_eq!(r.skipped.len(), MAX_DOCUMENT_FONT_FILES + 1, "one note for each file read, one for the rest"); assert!(r.skipped.last().is_some_and(|s| s.contains("6 more") && s.contains("200")), "{:?}", r.skipped.last()); let _ = std::fs::remove_dir_all(&folder); @@ -103,7 +108,51 @@ fn large_cjk_collections_are_within_the_limit() { fn a_missing_folder_loads_nothing() { let db = FontDb::with_font_dirs(vec![]); let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER)); - assert_eq!((r.faces, r.skipped.len()), (0, 0)); + assert_eq!((r.faces, r.scope, r.skipped.len()), (0, 0, 0)); +} + +#[test] +fn each_scope_sees_its_own_fonts_only() { + const FAMILY: &str = "DocFont Scope"; + let (a, b) = (temp_dir("scope-a"), temp_dir("scope-b")); + write_font(&a, "a.ttf", FAMILY, &['a']); + write_font(&b, "b.ttf", FAMILY, &['b', '\u{E123}']); + let db = FontDb::with_font_dirs(vec![]); + let (ra, rb) = (db.load_document_fonts(&a), db.load_document_fonts(&b)); + let (in_a, in_b) = (db.scoped(ra.scope), db.scoped(rb.scope)); + + assert!(in_a.face(FAMILY, "Regular").covers('a') && in_b.face(FAMILY, "Regular").covers('b')); + assert!(in_a.families().iter().any(|f| f == FAMILY) && in_a.styles(FAMILY) == ["Regular"]); + assert!(!db.has_family(FAMILY) && !db.families().iter().any(|f| f == FAMILY), "no document's fonts are shared"); + assert!(!db.scoped(0).has_family(FAMILY)); + // Fallback finds a character in the document's own fonts, never another document's. + let exclude = in_a.face(FALLBACK_FAMILY, "Regular").id(); + assert!(in_a.fallback_for('\u{E123}', exclude, None).is_none()); + assert!(in_b.fallback_for('\u{E123}', exclude, None).is_some_and(|f| f.family == FAMILY)); + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); +} + +#[test] +fn a_closed_scope_finds_nothing_and_reopening_reuses_the_faces() { + const FAMILY: &str = "DocFont Reopened"; + let folder = temp_dir("reopen"); + let file = write_font(&folder, "r.ttf", FAMILY, &['r']); + let db = FontDb::with_font_dirs(vec![]); + let r = db.load_document_fonts(&folder); + let face = db.scoped(r.scope).face(FAMILY, "Regular"); + db.close_scope(r.scope); + assert!(!db.scoped(r.scope).has_family(FAMILY), "closed"); + + let again = db.load_document_fonts(&folder); + assert!(Arc::ptr_eq(&db.scoped(again.scope).face(FAMILY, "Regular"), &face), "an unchanged file isn't read again"); + // A file changed since is read again. + std::fs::write(&file, font_with(FAMILY, &['r', 's']).unwrap()).unwrap(); + let changed = db.load_document_fonts(&folder); + let new_face = db.scoped(changed.scope).face(FAMILY, "Regular"); + assert!(new_face.covers('s') && new_face.id() != face.id()); + assert!(!db.scoped(again.scope).face(FAMILY, "Regular").covers('s'), "the document open before keeps what it opened with"); + let _ = std::fs::remove_dir_all(&folder); } #[test] diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 493d6a0..da498a2 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2239,6 +2239,7 @@ impl<'r> Importer<'r> { created: designcraft_doc::vars::now(), modified: 0, next_id: self.next_id, + font_scope: 0, }; Ok(d) } diff --git a/crates/render/src/glyphs.rs b/crates/render/src/glyphs.rs index 9c9b9da..a28ac9b 100644 --- a/crates/render/src/glyphs.rs +++ b/crates/render/src/glyphs.rs @@ -1,14 +1,14 @@ //! Glyph grids for the Glyphs panel: a font's characters drawn into square cells. -use designcraft_fonts::FontDb; +use designcraft_fonts::ScopedFonts; use vello_cpu::{RenderContext, Resources, kurbo, peniko}; use crate::Rendered; -/// Draw `chars` of `family`/`style` into a grid of `cols` columns of `cell`-pixel cells, in -/// `color` (straight RGBA) on transparency. Glyphs are scaled to the font's em and centred. -pub fn glyph_grid(family: &str, style: &str, chars: &[char], cols: u32, cell: u32, color: [u8; 4]) -> Rendered { - let db = FontDb::global(); +/// Draw `chars` of `family`/`style` (as `db`, a document's fonts, has it) into a grid of `cols` +/// columns of `cell`-pixel cells, in `color` (straight RGBA) on transparency. Glyphs are scaled to +/// the font's em and centred. +pub fn glyph_grid(db: &ScopedFonts<'_>, family: &str, style: &str, chars: &[char], cols: u32, cell: u32, color: [u8; 4]) -> Rendered { let face = db.face(family, style); let cols = cols.max(1); let rows = (chars.len() as u32).div_ceil(cols).max(1); @@ -45,10 +45,10 @@ pub fn glyph_grid(family: &str, style: &str, chars: &[char], cols: u32, cell: u3 Rendered { width: w as u32, height: h as u32, pixels } } -/// `text` set in `family`/`style` on one line, `height` pixels tall (the em fits the height), in -/// `color` on transparency — for font menus that preview each family. -pub fn text_line(family: &str, style: &str, text: &str, height: u32, color: [u8; 4]) -> Rendered { - let db = FontDb::global(); +/// `text` set in `family`/`style` (as `db`, a document's fonts, has it) on one line, `height` +/// pixels tall (the em fits the height), in `color` on transparency — for font menus that preview +/// each family. +pub fn text_line(db: &ScopedFonts<'_>, family: &str, style: &str, text: &str, height: u32, color: [u8; 4]) -> Rendered { let face = db.face(family, style); let upem = face.units_per_em().max(1.0); let k = height as f64 * 0.72 / upem; @@ -85,7 +85,8 @@ mod tests { #[test] fn grid_draws_each_cell() { - let img = glyph_grid(designcraft_fonts::DEFAULT_FAMILY, "Regular", &['A', 'B', 'C', ' '], 2, 40, [0, 0, 0, 255]); + let db = designcraft_fonts::FontDb::global().scoped(0); + let img = glyph_grid(&db, designcraft_fonts::DEFAULT_FAMILY, "Regular", &['A', 'B', 'C', ' '], 2, 40, [0, 0, 0, 255]); assert_eq!((img.width, img.height), (80, 80)); let ink = |cx: u32, cy: u32| { (cx * 40..cx * 40 + 40).flat_map(|x| (cy * 40..cy * 40 + 40).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 128).count() @@ -96,7 +97,8 @@ mod tests { #[test] fn text_line_draws_the_name() { - let img = text_line(designcraft_fonts::DEFAULT_FAMILY, "Regular", "Serif", 24, [0, 0, 0, 255]); + let db = designcraft_fonts::FontDb::global().scoped(0); + let img = text_line(&db, designcraft_fonts::DEFAULT_FAMILY, "Regular", "Serif", 24, [0, 0, 0, 255]); assert_eq!(img.height, 24); assert!(img.width > 30); let ink = (0..img.width).flat_map(|x| (0..img.height).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 128).count(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 222f1f2..0fda534 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1963,11 +1963,12 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } } "chars" => { + let fonts = crate::panels::fonts(app); egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:")); let fam = cur(d, "c.fontFamily", &cv["fontFamily"]).as_str().unwrap_or("").to_string(); egui::ComboBox::from_id_salt("psfam").selected_text(&fam).width(200.0).show_ui(ui, |ui| { - for f in designcraft_fonts::FontDb::global().families() { + for f in fonts.families() { if ui.selectable_label(f == fam, &f).clicked() { d.fields.insert("c.fontFamily".into(), json!(f)); } @@ -1977,7 +1978,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Style:")); let sty = cur(d, "c.fontStyle", &cv["fontStyle"]).as_str().unwrap_or("").to_string(); egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| { - for s in designcraft_fonts::FontDb::global().styles(&fam) { + for s in fonts.styles(&fam) { if ui.selectable_label(s == sty, &s).clicked() { d.fields.insert("c.fontStyle".into(), json!(s)); } @@ -2617,7 +2618,7 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }); ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace With")).font(semibold(12.0))); - let db = designcraft_fonts::FontDb::global(); + let db = crate::panels::fonts(app); let families = db.families(); let fam_opts: Vec<(&str, &str)> = families.iter().map(|f| (f.as_str(), f.as_str())).collect(); egui::Grid::new("ff_to").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index b950dcc..3755e18 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -1,6 +1,5 @@ //! Glyphs panel: every character of a font in a grid; click to insert it at the text cursor. -use designcraft_fonts::FontDb; use egui::{Sense, vec2}; use serde_json::json; @@ -32,7 +31,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { .unwrap_or_else(|| designcraft_fonts::DEFAULT_FAMILY.into()); st.style = a.as_ref().and_then(|a| a["chars"]["fontStyle"].as_str().map(str::to_string)).unwrap_or_else(|| "Regular".into()); } - let db = FontDb::global(); + let (db, scope) = (super::fonts(app), super::font_scope(app)); ui.horizontal(|ui| { egui::ComboBox::from_id_salt("glyph_family").selected_text(&st.family).width(140.0).show_ui(ui, |ui| { for f in db.families() { @@ -80,11 +79,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let ppp = ui.ctx().pixels_per_point(); let cell_px = (CELL * ppp).round() as u32; let ink = t.text_strong; - let key = egui::Id::new(("glyph_grid", &st.family, &st.style, &q, cols, cell_px, ink.to_array())); + let key = egui::Id::new(("glyph_grid", &st.family, &st.style, scope, &q, cols, cell_px, ink.to_array())); let tex: egui::TextureHandle = match ui.data(|d| d.get_temp::(key)) { Some(t) => t, None => { - let img = designcraft_render::glyphs::glyph_grid(&st.family, &st.style, &chars, cols, cell_px, ink.to_array()); + let img = designcraft_render::glyphs::glyph_grid(&db, &st.family, &st.style, &chars, cols, cell_px, ink.to_array()); let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); let h = ui.ctx().load_texture("glyph_grid", ci, egui::TextureOptions::LINEAR); ui.data_mut(|d| d.insert_temp(key, h.clone())); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0ef1256..b9bffd8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -102,6 +102,17 @@ pub fn sel_info(app: &DesignApp) -> Option { } /// Resolved attributes at the text selection (or the selected text frames). +/// The fonts the active document sees: its own (its `Document Fonts` folder) ahead of the shared +/// ones. +pub fn fonts(app: &DesignApp) -> designcraft_fonts::ScopedFonts<'static> { + designcraft_fonts::FontDb::global().scoped(font_scope(app)) +} + +/// The active document's font scope (0 without a document or fonts of its own). +pub fn font_scope(app: &DesignApp) -> u32 { + app.session.active().map_or(0, |d| d.doc.font_scope) +} + pub fn text_attrs(app: &mut DesignApp) -> Option { let v = app.session.execute("type.selectionAttrs", &json!({})).ok()?; if v.is_null() { None } else { Some(serde_json::to_value(v).ok()?) } @@ -189,7 +200,8 @@ fn paint_star(p: &egui::Painter, c: egui::Pos2, r: f32, filled: bool, color: egu /// The Font menu: search, favourites (★, Show Favorites Only), and each family's name shown in /// that family. pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, width: f32) { - let fams = designcraft_fonts::FontDb::global().families(); + let (fonts, scope) = (fonts(app), font_scope(app)); + let fams = fonts.families(); let mut favs = app.session.prefs.favorite_fonts.clone(); let mut pick = None; let mut favs_changed = false; @@ -239,10 +251,10 @@ pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, } ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, f, egui::FontId::proportional(12.0), t.text); // The name in its own face (rendered once per family and scale). - let key = egui::Id::new(("font_preview", f, (ppp * 100.0) as u32, t.text.to_array())); + let key = egui::Id::new(("font_preview", f, scope, (ppp * 100.0) as u32, t.text.to_array())); let tex: Option = ui.data(|d| d.get_temp(key)); let tex = tex.unwrap_or_else(|| { - let img = designcraft_render::glyphs::text_line(f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array()); + let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array()); let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR); ui.data_mut(|d| d.insert_temp(key, h.clone())); @@ -267,14 +279,14 @@ pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, let _ = app.run("prefs.set", json!({ "favoriteFonts": favs })); } if let Some(f) = pick { - let styles = designcraft_fonts::FontDb::global().styles(&f); + let styles = fonts.styles(&f); let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }; let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}})); } } pub fn font_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, current: &str, width: f32) { - let styles = designcraft_fonts::FontDb::global().styles(family); + let styles = fonts(app).styles(family); let mut pick = None; egui::ComboBox::from_id_salt("font_style").selected_text(if current.is_empty() { "—" } else { current }).width(width).show_ui(ui, |ui| { for s in &styles { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index b819bbf..648d7a9 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -986,7 +986,8 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { let sty = c["fontStyle"].as_str().unwrap_or("").to_string(); let fw = full_width(ui); // Family dropdown with a search segment on the left. - let fams = designcraft_fonts::FontDb::global().families(); + let fonts = super::fonts(app); + let fams = fonts.families(); let resp = widgets::dropdown(ui, "", fw); let r = resp.rect; icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(3.0, 3.0), vec2(14.0, 14.0)), "search", t.icon); @@ -1011,12 +1012,12 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { }); }); if let Some(f) = pick_fam { - let styles = designcraft_fonts::FontDb::global().styles(&f); + let styles = fonts.styles(&f); let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }; let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}})); } ui.add_space(1.0); - let styles = designcraft_fonts::FontDb::global().styles(&fam); + let styles = fonts.styles(&fam); let cur = styles.iter().position(|s| *s == sty); if let Some(k) = widgets::dropdown_list(ui, if sty.is_empty() { "—" } else { &sty }, fw, &styles, cur) { let _ = app.run("type.char", json!({"attrs": {"fontStyle": styles[k]}})); @@ -2096,7 +2097,7 @@ fn variable_font_axes(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, styl if family.is_empty() { return; } - let db = designcraft_fonts::FontDb::global(); + let db = super::fonts(app); let axes = db.axes(family, style); if axes.is_empty() { return; diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index 6ea91ed..caafdc1 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -107,15 +107,16 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) { let c = a["chars"].clone(); let fam = c["fontFamily"].as_str().unwrap_or("").to_string(); let sty = c["fontStyle"].as_str().unwrap_or("").to_string(); - let fams = designcraft_fonts::FontDb::global().families(); + let fonts = panels::fonts(app); + let fams = fonts.families(); let cur = fams.iter().position(|f| *f == fam); if let Some(k) = widgets::dropdown_list(ui, if fam.is_empty() { "—" } else { &fam }, 150.0, &fams, cur) { let f = fams[k].clone(); - let styles = designcraft_fonts::FontDb::global().styles(&f); + let styles = fonts.styles(&f); let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }; let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}})); } - let styles = designcraft_fonts::FontDb::global().styles(&fam); + let styles = fonts.styles(&fam); let cur = styles.iter().position(|s| *s == sty); if let Some(k) = widgets::dropdown_list(ui, if sty.is_empty() { "—" } else { &sty }, 90.0, &styles, cur) { let _ = app.run("type.char", json!({"attrs": {"fontStyle": styles[k]}})); diff --git a/docs/cjk.md b/docs/cjk.md index 85cbbe2..a577fd5 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -42,7 +42,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Ruby | group ruby only, fixed 50 % size, no options, doesn't affect leading | | Kenten | the IDML kinds and a custom character, drawn as characters; no position, size, alignment, colour or font | | Composite fonts | done: model, `style.compositeFont.*`, shaping, IDML, font list, replace and Preflight; no dialog | -| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; fallback by language (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); document fonts: a `Document Fonts` folder beside an opened file loads ahead of installed fonts, and Package copies the fonts used there (licence permitting). They stay loaded for the session (shared by every open document, not unloaded on close) and aren't read on the web | +| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; fallback by language (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); document fonts: the fonts of a `Document Fonts` folder beside an opened file belong to that document (composition, export, font menus), ahead of installed fonts of the same name, and are forgotten when it closes; Package copies the fonts used there (licence permitting). Font files stay in memory for the session (reopening a document reuses them) and aren't read on the web | | Everything else below | missing | Known bugs to fix first: From 76c0b797604b534bd9f505703066303f70b6d9ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 15:36:23 -0400 Subject: [PATCH 11/17] CJK 1.10: Package copies the fallback fonts that draw text Composition draws the characters a named font lacks with fallback faces (the language's CJK chain, then the generic fallbacks). Package now also copies their font files: it collects the distinct faces of the visible glyphs of every composed story (table cells and footnotes included) and copies those that aren't a named font's face by the same rules (restricted licence listed, bundled included with DesignCraft, no file noted, each file once). The report lists them under Fallback fonts. --- ROADMAP.md | 2 +- crates/engine/src/cmd/package.rs | 93 ++++++++++++++++++++++++++++++-- docs/cjk.md | 2 +- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2af25ae..fe69906 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,7 +146,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded. - Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML). - Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name). -- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files it uses in a Document Fonts folder (except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). +- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and the fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). - Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read. - Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 044c3f9..e0febf8 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -3,8 +3,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; +use designcraft_compose::ComposedStory; use designcraft_doc::{AssetId, Document}; -use designcraft_fonts::{FontFace, FontSource}; +use designcraft_fonts::{FaceRef, FontFace, FontSource}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; @@ -17,7 +18,7 @@ pub fn specs() -> Vec { "Package…", ["File"], None, - "{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files it uses in Document Fonts/ (unless their licence restricts it), an IDML copy, an optional PDF and a report → {dir, files, report}", + "{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy, an optional PDF and a report → {dir, files, report}", has_doc, package ), @@ -110,6 +111,28 @@ fn copy_font(face: &FontFace, dir: &Path, copied: &mut Vec, taken: &mut } } +/// Add the faces that draw the visible glyphs of `cs` (table cells and footnotes included) to +/// `out`, each once. +fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec) { + // Cells and footnotes hold composed stories of their own; tables nest only so deep. + if depth > 16 { + return; + } + for f in &cs.frames { + for g in f.lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.visible) { + if !out.iter().any(|o| o.id() == g.face.id()) { + out.push(g.face); + } + } + for c in f.tables.iter().flat_map(|t| &t.cells) { + drawn_faces(&c.text, depth + 1, out); + } + for n in &f.notes { + drawn_faces(&n.text, depth + 1, out); + } + } +} + fn package(s: &mut Session, p: &Value) -> Result { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?); let st = s.doc()?; @@ -125,14 +148,25 @@ fn package(s: &mut Session, p: &Value) -> Result { // The font files, and what the report says about each font. let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let (fonts_dir, mut copied, mut taken) = (dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER), Vec::new(), Vec::new()); - let mut font_lines = Vec::new(); + let (mut font_lines, mut named) = (Vec::new(), Vec::new()); for f in fonts.as_array().into_iter().flatten() { let (family, style) = (f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or("")); let missing = f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false); - let note = - if missing { " — MISSING".to_string() } else { copy_font(&db.face(family, style), &fonts_dir, &mut copied, &mut taken, &mut files) }; + // A missing font's text is drawn in its substitute, which isn't a fallback font. + let face = db.face(family, style); + named.push(face.id()); + let note = if missing { " — MISSING".to_string() } else { copy_font(&face, &fonts_dir, &mut copied, &mut taken, &mut files) }; font_lines.push(format!(" {family} {style}{note}\n")); } + // Fallback fonts: the faces that draw characters the named fonts lack. + let mut drawn = Vec::new(); + for sid in d.stories.keys() { + drawn_faces(&s.cache.get(&d, *sid, None), 0, &mut drawn); + } + drawn.retain(|f| !named.contains(&f.id())); + drawn.sort_by(|a, b| (&a.family, &a.style).cmp(&(&b.family, &b.style))); + let fallback_lines: Vec = + drawn.iter().map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(f, &fonts_dir, &mut copied, &mut taken, &mut files))).collect(); if p.get("idml").and_then(Value::as_bool).unwrap_or(true) { let path = dir.join(format!("{title}.idml")); std::fs::write(&path, designcraft_idml::export_idml(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; @@ -151,6 +185,10 @@ fn package(s: &mut Session, p: &Value) -> Result { } r += "Fonts\n"; r += &font_lines.concat(); + if !fallback_lines.is_empty() { + r += "\nFallback fonts (for characters the fonts above lack)\n"; + r += &fallback_lines.concat(); + } r += "\nLinks\n"; for l in links.as_array().into_iter().flatten() { r += &format!(" {} ({})\n", l["name"].as_str().unwrap_or(""), l["status"].as_str().unwrap_or("")); @@ -260,4 +298,49 @@ mod tests { assert_eq!(std::fs::metadata(fonts.join("free.ttf")).unwrap().len(), len); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn package_copies_the_fallback_fonts_that_draw_text() { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + use designcraft_fonts::testing::{font_with, with_fs_type}; + const NAMED: &str = "DocFont Fallback Named"; + const HELPER: &str = "DocFont Fallback Helper"; + const RESTRICTED: &str = "DocFont Fallback Restricted"; + // Characters no other font has: the named font lacks both, the document's other fonts + // draw them. + let (helped, restricted) = ('\u{F0A41}', '\u{F0A42}'); + let dir = std::env::temp_dir().join(format!("dc-package-fallback-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("named.ttf"), font_with(NAMED, &['N']).unwrap()).unwrap(); + std::fs::write(fonts.join("helper.ttf"), font_with(HELPER, &[helped]).unwrap()).unwrap(); + std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &[restricted]).unwrap(), 0x0002).unwrap()).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = format!("N{helped}{restricted}"); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); + s.execute("type.char", &json!({"fontFamily": NAMED, "fontStyle": "Regular"})).unwrap(); + let src = dir.join("src").join("Fallback.designcraft"); + s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap(); + let mut s = Session::new(); + assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 3); + + let out = dir.join("Pkg"); + let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); + let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); + assert!(packed_fonts.join("named.ttf").exists()); + assert!(packed_fonts.join("helper.ttf").exists(), "the fallback font that draws text is copied"); + assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps a fallback font out too"); + let report = r["report"].as_str().unwrap(); + let fallback = report.split("Fallback fonts").nth(1).unwrap_or_else(|| panic!("{report}")); + assert!(fallback.contains(&format!("{HELPER} Regular\n")), "{report}"); + assert!(fallback.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); + assert!(!fallback.contains(NAMED), "{report}"); + // The packaged document draws the same text with its fonts. + let doc = r["files"][0].as_str().unwrap().to_string(); + assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 2); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/docs/cjk.md b/docs/cjk.md index a577fd5..72c46c8 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -42,7 +42,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Ruby | group ruby only, fixed 50 % size, no options, doesn't affect leading | | Kenten | the IDML kinds and a custom character, drawn as characters; no position, size, alignment, colour or font | | Composite fonts | done: model, `style.compositeFont.*`, shaping, IDML, font list, replace and Preflight; no dialog | -| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; fallback by language (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); document fonts: the fonts of a `Document Fonts` folder beside an opened file belong to that document (composition, export, font menus), ahead of installed fonts of the same name, and are forgotten when it closes; Package copies the fonts used there (licence permitting). Font files stay in memory for the session (reopening a document reuses them) and aren't read on the web | +| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; fallback by language (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); document fonts: the fonts of a `Document Fonts` folder beside an opened file belong to that document (composition, export, font menus), ahead of installed fonts of the same name, and are forgotten when it closes; Package copies every font file that draws text, fallback fonts included (licence permitting). Font files stay in memory for the session (reopening a document reuses them) and aren't read on the web | | Everything else below | missing | Known bugs to fix first: From a08198e13935d8770e9535a3b59b35a869b160e4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 15:37:09 -0400 Subject: [PATCH 12/17] CJK 1.11: Chinese language names in their common spellings language_tag ignores case and punctuation and recognises Chinese in its common spellings and locale codes (Simplified Chinese, Chinese (Simplified), zh_CN, zh-Hans; Traditional Chinese, zh_TW, zh_HK, zh-Hant), and Japanese and Korean locale codes (ja_JP, ko_KR). Typographer's quotes for Chinese and Japanese follow the tag, so every spelling takes the same quotes. IDML import keeps the name as written. --- ROADMAP.md | 2 +- crates/doc/src/attrs.rs | 88 ++++++++++++++++++++++++++++------- crates/engine/src/cmd/text.rs | 49 +++++++++++++++++-- docs/cjk.md | 1 + 4 files changed, 119 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fe69906..f195feb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,7 +87,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. -- Language (Character panel ▸ Language, `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: 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). Hyphenation dictionaries for other languages aren't bundled yet. +- Language (Character panel ▸ Language, `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: 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; Chinese, Japanese and Korean are recognised in their common spellings and locale codes ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`, `ja_JP`, `ko-KR`). Hyphenation dictionaries for other languages aren't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index ff21dd7..24aac75 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -193,25 +193,43 @@ impl Digits { /// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese: /// Simplified"), for the shaper's localized forms (`locl`). A variant we don't know falls back to /// its language ("English: Australian" → `en`); an unknown language (or "[No Language]") has none. +/// Case and punctuation don't matter, and Chinese, Japanese and Korean also go by their other +/// common spellings and locale codes ("Simplified Chinese", "Chinese (Traditional)", "zh_CN", +/// "zh-Hant", "ja_JP", "ko-KR"). pub fn language_tag(language: &str) -> Option<&'static str> { - let l = language.trim().to_ascii_lowercase(); - let variant = match l.as_str() { - "english: usa" => Some("en-US"), - "english: uk" => Some("en-GB"), - "english: canadian" => Some("en-CA"), - "german: swiss 2006 reform" | "german: swiss" => Some("de-CH"), - "french: canadian" => Some("fr-CA"), - "portuguese: brazilian" => Some("pt-BR"), - "norwegian: bokmål" | "norwegian: bokmal" => Some("nb"), - "norwegian: nynorsk" => Some("nn"), - "chinese: simplified" => Some("zh-Hans"), - "chinese: traditional" => Some("zh-Hant"), + let l = language.to_lowercase(); + let words: Vec<&str> = l.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect(); + let first = words.first().copied().unwrap_or(""); + let has = |any: &[&str]| words.iter().any(|w| any.contains(w)); + if first == "zh" || has(&["chinese"]) { + // The script decides; then the name; then the region. + return Some(if has(&["hans"]) { + "zh-Hans" + } else if has(&["hant"]) { + "zh-Hant" + } else if has(&["simplified", "cn", "sg"]) { + "zh-Hans" + } else if has(&["traditional", "tw", "hk", "mo"]) { + "zh-Hant" + } else { + "zh" + }); + } + let variant = match words.join(" ").as_str() { + "english usa" => Some("en-US"), + "english uk" => Some("en-GB"), + "english canadian" => Some("en-CA"), + "german swiss 2006 reform" | "german swiss" => Some("de-CH"), + "french canadian" => Some("fr-CA"), + "portuguese brazilian" => Some("pt-BR"), + "norwegian bokmål" | "norwegian bokmal" => Some("nb"), + "norwegian nynorsk" => Some("nn"), _ => None, }; if variant.is_some() { return variant; } - Some(match l.split(':').next().unwrap_or("").trim() { + Some(match first { "english" => "en", "german" => "de", "french" => "fr", @@ -238,9 +256,8 @@ pub fn language_tag(language: &str) -> Option<&'static str> { "serbian" => "sr", "macedonian" => "mk", "turkish" => "tr", - "japanese" => "ja", - "korean" => "ko", - "chinese" => "zh", + "japanese" | "ja" => "ja", + "korean" | "ko" => "ko", "arabic" => "ar", "persian" | "farsi" => "fa", "urdu" => "ur", @@ -783,6 +800,45 @@ mod tests { assert_eq!(language_tag(""), None); } + #[test] + fn chinese_japanese_and_korean_names_map_in_their_common_spellings() { + for (name, tag) in [ + ("Chinese: Simplified", Some("zh-Hans")), + ("Simplified Chinese", Some("zh-Hans")), + ("Chinese Simplified", Some("zh-Hans")), + ("Chinese (Simplified)", Some("zh-Hans")), + (" chinese: SIMPLIFIED ", Some("zh-Hans")), + ("zh_CN", Some("zh-Hans")), + ("zh-CN", Some("zh-Hans")), + ("zh-Hans", Some("zh-Hans")), + ("zh-Hans-CN", Some("zh-Hans")), + ("Chinese: Traditional", Some("zh-Hant")), + ("Traditional Chinese", Some("zh-Hant")), + ("Chinese Traditional", Some("zh-Hant")), + ("Chinese (Traditional)", Some("zh-Hant")), + ("zh_TW", Some("zh-Hant")), + ("zh-TW", Some("zh-Hant")), + ("zh_HK", Some("zh-Hant")), + ("zh-HK", Some("zh-Hant")), + ("zh-Hant", Some("zh-Hant")), + ("Chinese", Some("zh")), + ("zh", Some("zh")), + ("Japanese", Some("ja")), + ("ja", Some("ja")), + ("ja_JP", Some("ja")), + ("ja-JP", Some("ja")), + ("Korean", Some("ko")), + ("ko_KR", Some("ko")), + ("ko-KR", Some("ko")), + ("English: USA", Some("en-US")), + ("German: Swiss 2006 Reform", Some("de-CH")), + ("Norwegian: Bokmål", Some("nb")), + ("[No Language]", None), + ] { + assert_eq!(language_tag(name), tag, "{name}"); + } + } + #[test] fn merge_and_resolve() { let mut a = CharAttrs { size: Some(10.0), ..Default::default() }; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index d81e169..dd88b7b 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -480,6 +480,12 @@ fn smart_quotes(s: &Session, text: &str) -> String { /// Typographer's quotes of a language: [double open, double close, single open, single close]. pub fn quote_marks(language: &str) -> [char; 4] { + // Chinese and Japanese go by the tag, which accepts every spelling of their names. + match designcraft_doc::language_tag(language) { + Some("zh-Hans") => return ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}'], + Some("zh" | "zh-Hant" | "ja") => return ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'], + _ => {} + } let l = language.to_ascii_lowercase(); if l.starts_with("german: swiss") || l.contains("swiss") { ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}'] @@ -508,10 +514,6 @@ pub fn quote_marks(language: &str) -> [char; 4] { ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}'] } else if l.starts_with("swedish") || l.starts_with("finnish") { ['\u{201D}', '\u{201D}', '\u{2019}', '\u{2019}'] - } else if l.starts_with("chinese: simplified") { - ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}'] - } else if l.starts_with("japanese") || l.starts_with("chinese") { - ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'] } else if l.starts_with("danish") { ['\u{00BB}', '\u{00AB}', '\u{203A}', '\u{2039}'] } else { @@ -1334,10 +1336,49 @@ mod language_tests { assert_eq!(quote_marks("Chinese: Traditional"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); assert_eq!(quote_marks("Chinese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); assert_eq!(quote_marks("Japanese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + // Other spellings of the language names take the same quotes. + assert_eq!(quote_marks("Simplified Chinese"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); + assert_eq!(quote_marks("zh_CN"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); + assert_eq!(quote_marks("Chinese (Traditional)"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("zh-TW"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("ja_JP"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); for l in ["Japanese", "Korean", "Chinese", "Chinese: Simplified", "Chinese: Traditional"] { assert!(!designcraft_compose::is_english(l), "{l}: no English hyphenation or spelling"); } } + + #[test] + fn idml_language_names_keep_their_spelling_and_pick_the_language() { + use std::io::Read; + // A stand-in for Songti SC (first in the Simplified Chinese chain; the real one when installed). + designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_with("Songti SC", &['直']).unwrap()); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "直"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 3})).unwrap(); + s.execute("type.char", &json!({"attrs": {"language": "Simplified Chinese"}})).unwrap(); + let idml = designcraft_idml::export_idml(&s.doc().unwrap().doc); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(idml.clone())).unwrap(); + let stories: String = (0..z.len()) + .map(|i| { + let mut f = z.by_index(i).unwrap(); + let mut x = String::new(); + if f.name().starts_with("Stories/") { + f.read_to_string(&mut x).unwrap(); + } + x + }) + .collect(); + assert!(stories.contains(r#"AppliedLanguage="$ID/Simplified Chinese""#), "{stories}"); + // The name comes back as written and sets the text as Simplified Chinese. + let back = designcraft_idml::import_idml(&idml).unwrap(); + let st = back.stories.values().find(|st| st.text.contains('直')).unwrap(); + let (_, base) = back.styles.resolve_para(&st.paras[0]); + assert_eq!(back.styles.resolve_char(&base, st.char_format_at(0)).language, "Simplified Chinese"); + let cs = designcraft_compose::compose_story(&back, st.id, &Default::default()); + let face = cs.frames[0].lines[0].glyphs.iter().find(|g| g.byte == 0).unwrap().face; + assert_eq!(face.family, "Songti SC"); + } } #[cfg(test)] diff --git a/docs/cjk.md b/docs/cjk.md index 72c46c8..ccb5a03 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -42,6 +42,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Ruby | group ruby only, fixed 50 % size, no options, doesn't affect leading | | Kenten | the IDML kinds and a custom character, drawn as characters; no position, size, alignment, colour or font | | Composite fonts | done: model, `style.compositeFont.*`, shaping, IDML, font list, replace and Preflight; no dialog | +| Languages | Japanese, Korean, Simplified and Traditional Chinese reach the shaper (`locl`), font fallback, line breaking and typographer's quotes; IDML language names are kept as written and recognised in their common spellings and locale codes ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`, `ja_JP`, `ko-KR`) | | CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; fallback by language (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); document fonts: the fonts of a `Document Fonts` folder beside an opened file belong to that document (composition, export, font menus), ahead of installed fonts of the same name, and are forgotten when it closes; Package copies every font file that draws text, fallback fonts included (licence permitting). Font files stay in memory for the session (reopening a document reuses them) and aren't read on the web | | Everything else below | missing | From 1ef187ad022733b851f719b334c15545848e7520 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 09:16:59 -0400 Subject: [PATCH 13/17] Text: missing glyphs show as boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A character the applied font lacks is drawn as that font's missing-glyph box (its .notdef, gid 0), on screen and in PDF, and Preflight lists the characters by font ("Missing glyphs in