diff --git a/ASSETS.md b/ASSETS.md index 4297ab421d..b074108d05 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -8,6 +8,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| Optional Khmer model, spelling/frequency dictionaries and preferred-form rules (embedded by `craft-khmer`) | SBBIC Khmer; sources include SEAlang (Doug Cooper) and SIL NRSI khmerlbdict | https://github.com/sbbic-khmer/aksara-khmer-word-processor/tree/31740e9d51d38171ae6119fc2d18a4a18fbfb2f1 | MIT (full notices in NOTICE) | Not in any build yet: the pinned shared crate refuses `CRAFT_KHMER_DIR` until maintainers classify the data. Once classified, the data becomes an explicit build input whose source and canonical hashes that crate verifies. No dictionaries are stored in this repository | | German hyphenation patterns (embedded by `hypher` 0.1.8, feature `german`) | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr | https://github.com/typst/hypher/blob/d81cc506416bffef2a75ee2dc969d4b41bc36613/patterns/hyph-de-1996.tex | MIT (copyright 2013–2024; full text in NOTICE) | Version 2024-02-28; reformed German spelling (2006), despite the `de-1996` filename; no separate old-spelling or Swiss patterns | | `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts | | `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts | diff --git a/Cargo.lock b/Cargo.lock index 98b181d660..a600e5f702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1075,6 +1075,19 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-khmer" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=0b478944aa27a5bef1f51508c57bd236f272326d#0b478944aa27a5bef1f51508c57bd236f272326d" +dependencies = [ + "brotli", + "brotli-decompressor", + "crc32fast", + "regex", + "serde_json", + "sha2", +] + [[package]] name = "craft-pdf" version = "0.1.0" @@ -1362,6 +1375,7 @@ dependencies = [ name = "designcraft-compose" version = "0.17.0" dependencies = [ + "craft-khmer", "designcraft-color", "designcraft-doc", "designcraft-fonts", @@ -5339,6 +5353,8 @@ version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash 0.2.0", + "indexmap", "itoa", "memchr", "serde", diff --git a/Cargo.toml b/Cargo.toml index dbf070673b..c0a088a491 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] +# Shared Khmer segmentation and proofreading; data is an explicit, hash-verified build input. +craft-khmer = { git = "https://github.com/storytold/craft-libs", rev = "0b478944aa27a5bef1f51508c57bd236f272326d", version = "=0.1.0" } # Shared reader and writer; publish the immutable revision before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false } # Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog. diff --git a/NOTICE b/NOTICE index 8334243333..8b5b45502d 100644 --- a/NOTICE +++ b/NOTICE @@ -235,3 +235,43 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Khmer language engine and optional language data +----------------------------------------------- +Shared craft-khmer is adapted from SBBIC Khmer’s Aksara at +31740e9d51d38171ae6119fc2d18a4a18fbfb2f1. The unchanged data is an optional, +hash-verified build input; no publisher documents or additional fonts are bundled. +SBBIC identifies internal work, the SEAlang frequency lists +(http://sealang.net/project/list/) and SIL NRSI’s Khmer line-breaking dictionary +(https://github.com/silnrsi/khmerlbdict) as sources. These materials are MIT +licensed; their copyright and permission notices follow. Builds without +CRAFT_KHMER_DIR omit this data, and craft-khmer currently refuses that setting, pending +maintainer classification of the data; no DesignCraft build contains it until then. +See the craft-khmer README and PROVENANCE.md for provenance and its limits, including the +historical Unicode notice for the CLDR-derived names in the frequency input. +The attached-glyph protection in composition was informed by visarutforthaipbs’s +DesignCraft #516. Shared-core contributions are credited in craft-khmer. + +MIT License + +Copyright (c) 2025 SBBIC Khmer +Copyright (c) 2015 SIL NRSI +Copyright (c) 2010, Doug Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ROADMAP.md b/ROADMAP.md index 963b8b8552..621da3c56a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,8 @@ - 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md). - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** minor (Pages panel parents and alternate layouts; File ▸ Export Layouts and Versions; placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** minor (optional Khmer composition; Pages panel parents and alternate layouts; File ▸ Export Layouts and Versions; placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 +- KM2: Khmer shaping keeps required conjunct/subscript substitutions when imported ligature or tracking settings disable optional ligatures; Latin controls remain unchanged. - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. @@ -390,6 +391,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m - **2026-11-04** · Striped strokes retain out-and-back cubic segments whose second control point is away from the coincident endpoints and first control point. A geometry regression checks the offset extent and both stripe bands. No readiness percentage change. - **2026-11-04** · Esperanto is a text language (Character › Language, `type.char {language: "Esperanto"}`, IDML `$ID/Esperanto`): tag `eo`, English-style quotes; not hyphenated yet (#1201). No readiness percentage change. +- **2026-11-03 · KM6:** The Story Editor counts Khmer words by their word boundaries, not real spaces. No readiness percentage change. +- **2026-11-03 · KM3:** Justified Khmer fills lines through real word spaces and Glyph Scaling, the industry-standard behavior: word boundaries open no gaps, and a line without a real space keeps its natural width. No readiness percentage change. +- **2026-11-03 · KM1:** Shared Khmer segmentation, authored ZWSP and cluster-safe composition in both composers. Data is an explicit optional build input; see [Khmer support](docs/khmer.md). No readiness percentage change. - **2026-11-02** · EPUB Export Options names a cover image file (PNG or JPEG, typed or chosen with Choose…) for the reflowable export; left empty, the first page stays the cover (#1934). No readiness percentage change. - **2026-11-02** · The Pen shows the segment a dragged smooth point shapes while it is dragged, and its rubber band curves out of a smooth point's direction line (#1438, first item). No readiness percentage change. diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index cd34918aa9..9e307491d2 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -9,6 +9,7 @@ description = "DesignCraft text composition: paragraph composer (Knuth–Plass), workspace = true [dependencies] +craft-khmer = { workspace = true } unicode-bidi = { workspace = true } unicode-segmentation = { workspace = true } unicode-bidi-mirroring = { workspace = true } diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 46fb3bdffa..9848effac5 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -99,9 +99,11 @@ impl Spacing { return ([moji_stretch + st, 0.0, 0.0], [moji_shrink + sh, 0.0, 0.0]); } let natural = g.adv / self.glyph_desired.max(0.01); + // Khmer letters take glyph scaling but no letter spacing (see `khmer::letter_gap`). + let letter = if crate::khmer::character(g.ch) { 0.0 } else { g.space }; ( - [moji_stretch, g.space * (self.letter_max - self.letter_desired).max(0.0), natural * (self.glyph_max - self.glyph_desired).max(0.0)], - [moji_shrink, g.space * (self.letter_desired - self.letter_min).max(0.0), natural * (self.glyph_desired - self.glyph_min).max(0.0)], + [moji_stretch, letter * (self.letter_max - self.letter_desired).max(0.0), natural * (self.glyph_max - self.glyph_desired).max(0.0)], + [moji_shrink, letter * (self.letter_desired - self.letter_min).max(0.0), natural * (self.glyph_desired - self.glyph_min).max(0.0)], ) } /// Word-space stretch and shrink of space glyph `g` (see [`Glyph::is_word_space`]); none @@ -187,7 +189,8 @@ struct Item { zone_from: usize, /// An emergency break inside a word wider than the measure. emergency: bool, - /// Glue: stretch past the maximum word spacing ([`Spacing::space_overflow`]). + /// Glue: stretch past the maximum word spacing ([`Spacing::space_overflow`]). A justified + /// Khmer box: its share of the line's slack rating ([`crate::khmer::slack_unit`]). over: f64, } @@ -463,6 +466,9 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S let mut bx = Item::boxed(g.adv, st, sh); bx.hang = hang_left(g, sp) - g.moji.start; bx.start_elastic = crate::mojikumi::start_elastic(g, sp.justify, sp.kinsoku_priority != 3); + if sp.justify { + bx.over = crate::khmer::slack_unit(g); + } it.push(bx); ig.push(i); let next_is_space = glyphs.get(i + 1).is_none_or(|n| n.is_space()); @@ -897,7 +903,7 @@ fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain: /// 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)); + let allowed = |j: usize| matches!((glyphs.get(j.wrapping_sub(1)), glyphs.get(j)), (Some(a), Some(b)) if a.break_after != Some(false) && !a.no_break && kinsoku_allows(a.ch, b.ch)); (start + 1..=i).rev().find(|&j| allowed(j)).unwrap_or(i) } diff --git a/crates/compose/src/khmer.rs b/crates/compose/src/khmer.rs new file mode 100644 index 0000000000..8fb60c0acd --- /dev/null +++ b/crates/compose/src/khmer.rs @@ -0,0 +1,193 @@ +//! Khmer lexical boundaries from the shared Aksara-derived core. The composer owns shaped +//! cluster safety and justification. The attached-glyph protection adapts ideas from +//! @visarutforthaipbs's DesignCraft PR #516: +//! https://github.com/storytold/designcraft/pull/516. Khmer lexical decisions use craft-khmer, +//! as in WordCraft, rather than the ICU4X segmenters proposed in that contribution. +//! +//! A boundary only permits a line break: it has no width, so justification never opens a gap +//! there. As is standard in page layout, a line fills through its real word spaces and the +//! paragraph's glyph scaling; a line without a real space keeps its natural width until the author adds one. +use std::{ + collections::HashMap, + ops::Range, + sync::{Arc, Mutex, OnceLock}, +}; + +use crate::shape::Glyph; +use unicode_segmentation::UnicodeSegmentation; + +pub(crate) fn character(c: char) -> bool { + matches!(c, '\u{1780}'..='\u{17ff}' | '\u{19e0}'..='\u{19ff}') +} +fn mark(c: char) -> bool { + matches!(c, '\u{17b4}'..='\u{17d3}' | '\u{17dd}' | '\u{200c}' | '\u{200d}') +} +fn closing(c: char) -> bool { + matches!(c, 'ៗ' | '។' | '៕' | '៖' | '៘' | '៚' | ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}' | '»' | '”') +} + +#[derive(Default)] +struct Cache { + values: HashMap, Arc<[usize]>>, + bytes: usize, +} +// A bounded cache avoids recomputing unchanged paragraphs during whole-story recomposition. +// Scoring happens outside the lock; oversized inputs are computed but never retained. Recomposing +// walks paragraphs in order, so a book past this limit would clear each entry before its next use. +fn boundaries(text: &str) -> Arc<[usize]> { + const MAX_BYTES: usize = 64 << 20; + static CACHE: OnceLock> = OnceLock::new(); + let cache = CACHE.get_or_init(Default::default); + if let Some(found) = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).values.get(text) { + return found.clone(); + } + let breaks: Arc<[usize]> = if craft_khmer::data_available() { + craft_khmer::line_breaks(text).into() + } else { + text.char_indices().filter_map(|(i, c)| (c == '\u{200b}').then_some(i + c.len_utf8())).collect() + }; + let size = text.len().saturating_add(std::mem::size_of_val(breaks.as_ref())); + if size <= MAX_BYTES { + let mut cache = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if cache.bytes.saturating_add(size) > MAX_BYTES { + cache.values.clear(); + cache.bytes = 0; + } + if !cache.values.contains_key(text) { + cache.values.insert(text.into(), breaks.clone()); + cache.bytes += size; + } + } + breaks +} + +/// Tokens separated by whitespace that hold a letter or digit. Detached marks, repetition signs +/// and punctuation are not words. Unicode counts Khmer vowel signs as alphabetic. +fn spaced_words(text: &str) -> usize { + let word_char = |c: char| if character(c) { ('\u{1780}'..='\u{17b3}').contains(&c) || c.is_numeric() } else { c.is_alphanumeric() }; + text.split_whitespace().filter(|token| token.chars().any(word_char)).count() +} + +fn segmented_words(text: &str) -> usize { + let mut words = 0usize; + let mut end = 0; + for range in craft_khmer::word_ranges(text) { + words = words.saturating_add(text.get(end..range.start).map_or(0, spaced_words)); + let letter = text.get(range.clone()).is_some_and(|word| word.chars().any(|c| ('\u{1780}'..='\u{17b3}').contains(&c))); + words = words.saturating_add(usize::from(letter)); + end = range.end; + } + words.saturating_add(text.get(end..).map_or(0, spaced_words)) +} + +/// Words in one paragraph holding Khmer, by the same rules as WordCraft: word spans from the shared +/// model, whitespace tokens for other text. Counts recur on every frame of the Story Editor, so +/// paragraphs are cached by their text; each gets its own refinement budget. A count walks them +/// in order, so the bound sits far above real books: past it, entries clear before their next use. +fn paragraph_words(text: &str) -> usize { + const MAX_TEXT: usize = 64 << 20; + const MAX_PARAGRAPH: usize = 64 << 10; + #[derive(Default)] + struct Counts { + words: HashMap, usize>, + bytes: usize, + } + static COUNTS: OnceLock> = OnceLock::new(); + let counts = COUNTS.get_or_init(Default::default); + if let Some(words) = counts.lock().unwrap_or_else(std::sync::PoisonError::into_inner).words.get(text).copied() { + return words; + } + let words = segmented_words(text); + if text.len() <= MAX_PARAGRAPH { + let mut counts = counts.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if counts.bytes.saturating_add(text.len()) > MAX_TEXT { + counts.words.clear(); + counts.bytes = 0; + } + if !counts.words.contains_key(text) { + counts.words.insert(text.into(), words); + counts.bytes = counts.bytes.saturating_add(text.len()); + } + } + words +} + +/// Words in `text` for document statistics: Khmer counts by its word boundaries, not by the real +/// spaces that justification uses; other text counts by whitespace. Without the data, authored +/// zero-width spaces still separate Khmer words. +pub fn word_count(text: &str) -> usize { + if !text.chars().any(character) { + return text.split_whitespace().count(); + } + if !craft_khmer::data_available() { + return text.split('\u{200b}').map(spaced_words).fold(0, usize::saturating_add); + } + text.split(['\n', '\r']) + .map(|paragraph| if paragraph.chars().any(character) { paragraph_words(paragraph) } else { spaced_words(paragraph) }) + .fold(0, usize::saturating_add) +} + +/// Letter spacing never reaches Khmer letters or the glyphs of a shaped cluster, including across +/// an authored control between another script and Khmer. Other text is not affected. +pub(crate) fn letter_gap(line: &[Glyph], i: usize) -> bool { + let khmer_at = |j: usize| line.get(j).is_some_and(|g| character(g.ch)); + let (Some(_), Some(next)) = (line.get(i), line.get(i + 1)) else { return false }; + !khmer_at(i) && !khmer_at(i + 1) && !(matches!(next.ch, '\u{200b}' | '\u{2060}') && khmer_at(i + 2)) +} +/// How the breaker rates slack on a justified Khmer line: lines without a real space stay short, +/// so a short line costs in proportion to its text rather than being impossible to rate. This is +/// never applied to a glyph's width. +pub(crate) fn slack_unit(g: &Glyph) -> f64 { + if character(g.ch) { g.adv * 0.05 } else { 0.0 } +} + +/// Model and authored opportunities must also be source-grapheme and shaped-cluster edges. +/// Hard exclusions remain separate from dictionary decisions, allowing cluster-safe emergency +/// wrapping of overlong unknown words without overriding WJ, punctuation or No Break. +pub(crate) fn apply(glyphs: &mut [Glyph], text: &str, range: Range) { + let Some(span) = text.get(range.clone()) else { return }; + if !span.chars().any(character) { + return; + } + let breaks = boundaries(span); + let clusters: Vec<_> = span.grapheme_indices(true).map(|(i, _)| i).collect(); + for i in 0..glyphs.len().saturating_sub(1) { + let (left, right) = glyphs.split_at_mut(i + 1); + let (Some(g), Some(next)) = (left.last_mut(), right.first()) else { continue }; + // An authored control has no width, so letter spacing has nothing to work with. + if matches!(g.ch, '\u{200b}' | '\u{2060}') { + g.space = 0.0; + } + if next.byte < range.start || next.byte >= range.end || g.list_label || next.list_label { + continue; + } + if next.byte == g.byte { + if character(g.ch) || character(next.ch) { + g.break_after = Some(false); + } + continue; + } + let at = next.byte - range.start; + let before = span.get(..at).and_then(|s| s.chars().next_back()); + let after = span.get(at..).and_then(|s| s.chars().next()); + let (Some(before), Some(after)) = (before, after) else { continue }; + let khmer_edge = + character(before) || character(after) || matches!(before, '\u{200b}' | '\u{2060}') || matches!(after, '\u{200b}' | '\u{2060}'); + if !khmer_edge { + continue; + } + let forbidden = clusters.binary_search(&at).is_err() + || matches!(before, '\u{17d2}' | '័') + || before == '\u{2060}' + || after == '\u{2060}' + || after == '\u{200b}' + || mark(after) + || closing(after) + || matches!(before, '(' | '[' | '{' | '«' | '“'); + if forbidden { + g.break_after = Some(false); + } else if g.break_after != Some(false) && !g.no_break && !next.no_break && !g.locked_advance && breaks.binary_search(&at).is_ok() { + g.break_after = Some(true); + } + } +} diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index abac729856..7bb9c6d815 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -17,6 +17,8 @@ pub mod grep; mod grid; pub mod hyphen; pub mod kenten; +mod khmer; +pub use khmer::word_count; mod mojikumi; mod notes; pub mod optical; @@ -1223,6 +1225,7 @@ fn apply_nested_line_styles(input: NestedLineStyleInput<'_, '_, '_>) { }; (col.width() - pp.left_indent - pp.right_indent - ind).max(1.0) }; + khmer::apply(&mut gl, &story.text, prange.clone()); apply_desired_spacing(&mut gl, pp); mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi); let mut hy = hyphenation_points(&story.text, &gl, pp, hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), base_chars)); @@ -2452,6 +2455,7 @@ fn shape_paragraph(input: ParagraphShapeInput<'_, '_>) -> ShapedParagraph { Some(if pp.direction == designcraft_doc::TextDirection::RightToLeft { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() }), ), ); + khmer::apply(&mut glyphs, &story.text, prange.clone()); apply_desired_spacing(&mut glyphs, pp); mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi); let mut hyph_after = hyphenation_points(&story.text, &glyphs, pp, hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), base_chars)); @@ -3468,7 +3472,11 @@ fn apply_desired_spacing(glyphs: &mut [Glyph], pp: &ParaProps) { if g.locked_advance { continue; } - if g.is_word_space() { + if khmer::character(g.ch) { + g.adv *= gs; + g.sx *= gs; + g.dx *= gs; + } else if g.is_word_space() { g.adv += g.space * (ws - 1.0); } else if !g.is_space() && g.adv > 0.0 { g.adv = g.adv * gs + ls * g.space; @@ -4659,6 +4667,11 @@ fn layout_line( let seg_justifies = last_tab.is_none_or(|(_, left)| left); // Spaces inside a jidori run keep the run's fitted width, as in the breaker. let spaces: Vec = line.iter().enumerate().skip(seg).filter(|(_, g)| g.is_justify_space() && !g.locked_advance).map(|(i, _)| i).collect(); + // A Khmer line stretches only with a real word space to take the length: its word boundaries + // have no width. Without one it keeps its natural width (the author adds a space where it + // belongs), though it still shrinks to fit, as far as glyph scaling allows. + let khmer_line = line.get(seg..).is_some_and(|text| text.iter().any(|g| khmer::character(g.ch))); + let khmer_natural = khmer_line && extra >= 0.0 && !spaces.iter().any(|&i| line[i].is_word_space()); let align = page_alignment(pp.align, left_page); let justify_this = align.is_justified() && (!last || align == Align::FullyJustified || forced_mid) && seg_justifies; // A justified paragraph's last line may have been composed with shrunk spaces: shrink it too. @@ -4675,19 +4688,21 @@ fn layout_line( if (justify_this || squeeze_last) && (extra >= 0.0 || sp.kinsoku_priority != 3) { extra = mojikumi::distribute(line.get_mut(seg..).unwrap_or_default(), extra, add.get_mut(seg..).unwrap_or_default()); } - if (justify_this || squeeze_last) && !spaces.is_empty() { + if (justify_this || squeeze_last) && !spaces.is_empty() && !khmer_natural { let rebased: Vec = spaces.iter().map(|&i| i - seg).collect(); let (seg_line, seg_add, seg_scale) = (line.get(seg..).unwrap_or(&[]), add.get_mut(seg..).unwrap_or(&mut []), scale.get_mut(seg..).unwrap_or(&mut [])); distribute(seg_line, &rebased, extra, sp, seg_add, seg_scale); - } else if justify_this && spaces.is_empty() && line.len() > seg + 1 && !last && extra < 0.0 { + } else if ((justify_this && !last) || (khmer_line && (justify_this || squeeze_last))) && spaces.is_empty() && line.len() > seg + 1 && extra < 0.0 + { // An overfull single word shrinks only as far as the minimum letter spacing and glyph scaling allow. let (seg_line, seg_add, seg_scale) = (line.get(seg..).unwrap_or(&[]), add.get_mut(seg..).unwrap_or(&mut []), scale.get_mut(seg..).unwrap_or(&mut [])); distribute(seg_line, &[], extra, sp, seg_add, seg_scale); - } else if justify_this && spaces.is_empty() && line.len() > seg + 1 && !last { + } else if justify_this && (spaces.is_empty() || khmer_natural) && line.len() > seg + 1 && !last { // Single word: Single Word Justification. match pp.single_word_justify { + Align::FullyJustified if khmer_line => {} Align::FullyJustified => { let gaps: Vec<_> = line .iter() @@ -4917,7 +4932,9 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: & .collect(); // Letter gaps: between visible glyphs (not after the line's last glyph). let last_box = line.iter().rposition(|g| !g.is_justify_space() && g.adv > 0.0).unwrap_or(0); - let is_box = |i: usize, g: &Glyph| i < last_box && !g.is_justify_space() && g.adv > 0.0 && (!g.locked_advance || g.break_after != Some(false)); + let is_box = |i: usize, g: &Glyph| { + i < last_box && !g.is_justify_space() && g.adv > 0.0 && (!g.locked_advance || g.break_after != Some(false)) && khmer::letter_gap(line, i) + }; let letter: Vec = line .iter() .enumerate() @@ -4985,6 +5002,14 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: & scale[i] = (w + sign * tg * gcap / yg) / w; } } + // The attached (zero-advance) glyphs of a Khmer cluster scale with their base. + if let Some(factor) = scale.iter().copied().find(|s| *s != 1.0) { + for (i, g) in line.iter().enumerate() { + if g.adv <= 0.0 && !g.locked_advance && khmer::character(g.ch) { + scale[i] = factor; + } + } + } } } @@ -5023,6 +5048,7 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph { || g.ch == story::INDENT_HERE || g.ch == story::RIGHT_INDENT_TAB || g.ch == story::TABLE_ANCHOR + || g.ch == '\u{200b}' || g.ch == shape::SOFT_HYPHEN || g.ch == shape::HIDDEN); PlacedGlyph { diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 1de4b823c5..902ab82702 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -871,29 +871,36 @@ fn shape_run_raw( continue; } } - let special = matches!( - c, - '\t' | story::FORCED_LINE_BREAK - | story::PAGE_NUMBER - | story::NEXT_PAGE_NUMBER - | story::PREV_PAGE_NUMBER - | story::SECTION_MARKER - | story::COLUMN_BREAK - | story::FRAME_BREAK - | story::PAGE_BREAK - | story::ODD_PAGE_BREAK - | story::EVEN_PAGE_BREAK - | story::INDENT_HERE - | story::RIGHT_INDENT_TAB - | story::TABLE_ANCHOR - | designcraft_doc::FOOTNOTE_REF - | designcraft_doc::ENDNOTE_REF - | designcraft_doc::NOTE_MARK - | designcraft_doc::XREF_MARK - | designcraft_doc::ANCHOR_MARK - | designcraft_doc::INDEX_MARK - | designcraft_doc::OBJECT_MARK - ) || designcraft_doc::vars::var_index(c).is_some(); + // Preserve joining context in other scripts (notably Arabic around WJ). Khmer + // needs standalone controls so an authored ZWSP can carry an exact line boundary. + let khmer_control = matches!(c, '\u{200b}' | '\u{2060}') + && (text.get(..i).and_then(|s| s.chars().next_back()).is_some_and(crate::khmer::character) + || text.get(i + c.len_utf8()..).and_then(|s| s.chars().next()).is_some_and(crate::khmer::character)); + let special = khmer_control + || matches!( + c, + '\t' | story::FORCED_LINE_BREAK + | story::PAGE_NUMBER + | story::NEXT_PAGE_NUMBER + | story::PREV_PAGE_NUMBER + | story::SECTION_MARKER + | story::COLUMN_BREAK + | story::FRAME_BREAK + | story::PAGE_BREAK + | story::ODD_PAGE_BREAK + | story::EVEN_PAGE_BREAK + | story::INDENT_HERE + | story::RIGHT_INDENT_TAB + | story::TABLE_ANCHOR + | designcraft_doc::FOOTNOTE_REF + | designcraft_doc::ENDNOTE_REF + | designcraft_doc::NOTE_MARK + | designcraft_doc::XREF_MARK + | designcraft_doc::ANCHOR_MARK + | designcraft_doc::INDEX_MARK + | designcraft_doc::OBJECT_MARK + ) + || designcraft_doc::vars::var_index(c).is_some(); if special { flush(seg_start, i, &seg_face, out); seg_start = i + c.len_utf8(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a47e27f457..605d37b2fc 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1,3 +1,4 @@ +mod khmer; use designcraft_doc::build::NewDocument; use designcraft_doc::{Align, Composer, Document, ParaAttrs, ParaFormat, SpreadRef}; use designcraft_geom::Rect; diff --git a/crates/compose/src/tests/khmer.rs b/crates/compose/src/tests/khmer.rs new file mode 100644 index 0000000000..177a94c138 --- /dev/null +++ b/crates/compose/src/tests/khmer.rs @@ -0,0 +1,415 @@ +use super::*; +use std::collections::BTreeMap; + +fn khmer_font_available() -> bool { + let db = designcraft_fonts::FontDb::global(); + let face = db.face("Khmer Sangam MN", "Regular"); + let available = face.glyph_for('ក') != 0 || db.fallback_for('ក', face.id(), None).is_some(); + if !available { + eprintln!("skipped Khmer layout: install a Khmer font or supply craft-fonts"); + } + available +} + +fn fixture(text: &str, width: f64, composer: Composer, align: Align) -> (Document, StoryId) { + let (mut doc, sid, _) = doc_with( + text, + Rect::new(0.0, 0.0, width, 4000.0), + ParaAttrs { composer: Some(composer), align: Some(align), hyphenate: Some(false), ..Default::default() }, + ); + doc.story_mut(sid).unwrap().format_chars(0..text.len(), |f| { + f.over.font_family = Some("Khmer Sangam MN".into()); + f.over.language = Some("Khmer".into()); + f.over.size = Some(14.0); + }); + (doc, sid) +} +fn cluster_geometry(cs: &ComposedStory) -> BTreeMap> { + let mut groups: BTreeMap> = BTreeMap::new(); + for g in cs.frames.iter().flat_map(|f| &f.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible) { + groups.entry(g.byte).or_default().push((g.gid, g.x, g.y)); + } + for cluster in groups.values_mut() { + let first = cluster[0].1; + for g in cluster { + g.1 -= first; + } + } + groups +} + +/// With a required pack, the word count's data-only checks below must not pass by skipping. +#[test] +fn required_data_pack_is_available() { + if option_env!("CRAFT_KHMER_REQUIRED") == Some("1") { + assert!(craft_khmer::data_available(), "required Khmer data must activate the composition tests"); + } +} + +#[test] +fn khmer_authored_boundaries_survive_formatting_and_emergency_wraps() { + if !khmer_font_available() { + return; + } + let manual = "មូល\u{200b}ដ្ឋាន"; + let split = "មូល\u{200b}".len(); + for composer in [Composer::Paragraph, Composer::SingleLine] { + let (wide, sid) = fixture(manual, 1000.0, composer, Align::Left); + let natural = compose_story(&wide, sid, &ComposeOptions::default()); + let prefix_width: f64 = all_lines(&natural)[0].glyphs.iter().filter(|g| g.byte < split).map(|g| g.adv).sum(); + let (mut doc, sid) = fixture(manual, prefix_width + 0.1, composer, Align::Left); + doc.story_mut(sid).unwrap().format_chars(split..manual.len(), |f| f.over.fill = Some("[Black]".into())); + let cs = compose_story(&doc, sid, &ComposeOptions::default()); + assert_eq!(all_lines(&cs)[0].range.end, split, "manual opportunity inside a model word"); + assert_eq!(all_lines(&cs)[1].range.start, split); + assert!(cs.frames.iter().flat_map(|f| &f.lines).flat_map(|l| &l.glyphs).filter(|g| g.rendered_char == '\u{200b}').all(|g| !g.visible)); + let (doc, sid) = fixture(manual, 150.0, composer, Align::FullyJustified); + let justified = compose_story(&doc, sid, &ComposeOptions::default()); + let line = all_lines(&justified)[0]; + let natural_end = all_lines(&natural)[0].end_x; + assert!((line.end_x - natural_end).abs() < 0.001, "a ZWSP permits a break; it never opens a gap"); + for (byte, cluster) in cluster_geometry(&justified) { + let reference = cluster_geometry(&natural); + for (a, b) in cluster.iter().zip(&reference[&byte]) { + assert_eq!(a.0, b.0); + assert!((a.1 - b.1).abs() < 0.001 && (a.2 - b.2).abs() < 0.001); + } + } + for text in ["ខ្មែរ\u{2060}ភាសា", "គ្រប់ៗគ្នា។", "ក្ដៅខ្លាំង", "ក\u{200b}ិខ្មែរ"] + { + let (mut doc, sid) = fixture(text, 8.0, composer, Align::LeftJustified); + // A shaping boundary inside the first syllable must not become a line + // boundary: source clusters still protect the subscript/vowel. + doc.story_mut(sid).unwrap().format_chars(3..text.len(), |f| f.over.size = Some(14.1)); + let cs = compose_story(&doc, sid, &ComposeOptions::default()); + for line in all_lines(&cs).into_iter().filter(|l| l.range.end < text.len()) { + assert!(!line.range.is_empty(), "{composer:?}: a shaped cluster must not split across lines"); + let before = text[..line.range.end].chars().next_back().unwrap(); + let after = text[line.range.end..].chars().next().unwrap(); + assert!(!matches!(before, '\u{17d2}' | '\u{2060}')); + assert!(!matches!(after, 'ៗ' | '។' | '\u{2060}' | '\u{17b4}'..='\u{17d3}')); + } + assert_eq!(doc.story(sid).unwrap().text, text); + } + let (mut doc, sid) = fixture("គ្រប់ៗគ្នា", 8.0, composer, Align::Left); + doc.story_mut(sid).unwrap().format_chars(0.."គ្រប់ៗគ្នា".len(), |f| f.over.no_break = Some(true)); + assert_eq!(compose_story(&doc, sid, &ComposeOptions::default()).line_count(), 1); + } +} + +#[test] +fn khmer_mixed_text_keeps_other_scripts_and_arabic_word_joiner_keeps_joining() { + let wide = Rect::new(0.0, 0.0, 1000.0, 1000.0); + for text in ["The quick brown fox jumps over the lazy dog.", "日本語の文字組版を確認します。"] { + let (doc, sid, _) = doc_with(text, wide, ParaAttrs::default()); + let original = compose_story(&doc, sid, &ComposeOptions::default()); + let mixed = format!("{text} ខ្មែរ"); + let (doc, sid, _) = doc_with(&mixed, wide, ParaAttrs::default()); + let combined = compose_story(&doc, sid, &ComposeOptions::default()); + let geometry = |story: &ComposedStory| { + all_lines(story)[0].glyphs.iter().filter(|g| g.byte < text.len()).map(|g| (g.gid, g.x, g.y, g.adv, g.sx)).collect::>() + }; + assert_eq!(geometry(&original), geometry(&combined)); + } + let mut composed = Vec::new(); + for text in ["ببب", "بب\u{2060}ب"] { + let (mut doc, sid, _) = doc_with(text, wide, ParaAttrs::default()); + doc.settings.glyph_fallback = true; + let cs = compose_story(&doc, sid, &ComposeOptions::default()); + if all_lines(&cs)[0].glyphs.iter().any(|g| g.gid == 0) { + return; // No Arabic font on this host. + } + composed.push(all_lines(&cs)[0].glyphs.iter().filter(|g| g.visible).map(|g| (g.gid, g.adv)).collect::>()); + } + assert_eq!(composed[0], composed[1], "WJ must not interrupt Arabic shaping"); +} + +const WORD: &str = "ភាសាខ្មែរ"; + +/// A font with fixed metrics, so these tests depend on neither a host font nor the data pack. +fn synthetic_font() -> &'static str { + const FAMILY: &str = "DC Test Khmer"; + static ADDED: std::sync::Once = std::sync::Once::new(); + ADDED.call_once(|| { + let glyphs = [('ភ', 'a'), ('ា', 'b'), ('ស', 'c'), ('ខ', 'd'), ('្', 'e'), ('ម', 'f'), ('ែ', 'g'), ('រ', 'h'), (' ', ' ')]; + designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_mapping(FAMILY, &glyphs).unwrap()); + }); + FAMILY +} +fn synthetic(text: &str, width: f64, composer: Composer, align: Align) -> (Document, StoryId) { + let family = synthetic_font(); + let (mut doc, sid) = fixture(text, width, composer, align); + doc.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(family.into())); + (doc, sid) +} +fn word_width(composer: Composer) -> f64 { + let (doc, sid) = synthetic(WORD, 10000.0, composer, Align::Left); + all_lines(&compose_story(&doc, sid, &ComposeOptions::default()))[0].end_x +} +/// (advance, horizontal scale) of each glyph at its source byte, in reading order. +fn glyph_metrics(cs: &ComposedStory) -> BTreeMap> { + let mut out: BTreeMap> = BTreeMap::new(); + for g in cs.frames.iter().flat_map(|f| &f.lines).flat_map(|l| &l.glyphs) { + out.entry(g.byte).or_default().push((g.adv, g.sx)); + } + out +} +fn glyph_scaling_limits(doc: &mut Document, sid: StoryId, text: &str) { + doc.story_mut(sid).unwrap().format_paras(0..text.len(), |f| { + f.para.word_space_min = Some(1.0); + f.para.word_space_desired = Some(1.0); + f.para.word_space_max = Some(1.0); + f.para.letter_space_min = Some(0.0); + f.para.letter_space_desired = Some(0.0); + f.para.letter_space_max = Some(0.0); + f.para.glyph_scale_min = Some(0.96); + f.para.glyph_scale_desired = Some(1.0); + f.para.glyph_scale_max = Some(1.04); + f.para.single_word_justify = Some(Align::Left); + }); +} + +#[test] +fn word_boundaries_never_open_a_gap_and_real_spaces_take_the_length() { + // Lines without a real space (their boundaries are authored), then lines with them. + let text = format!("{} {}", [WORD; 24].join("\u{200b}"), [WORD; 24].join(" ")); + for composer in [Composer::Paragraph, Composer::SingleLine] { + let width = word_width(composer) * 7.5; + let (wide, sid) = synthetic(&text, 100000.0, composer, Align::Left); + let natural = glyph_metrics(&compose_story(&wide, sid, &ComposeOptions::default())); + let space = text.find(' ').map(|at| natural[&at][0].0).unwrap(); + for align in [Align::LeftJustified, Align::FullyJustified] { + let (doc, sid) = synthetic(&text, width, composer, align); + let cs = compose_story(&doc, sid, &ComposeOptions::default()); + let (mut spaced, mut unspaced, mut grown) = (0, 0, false); + for line in all_lines(&cs) { + for g in &line.glyphs { + if g.rendered_char == ' ' { + grown |= g.adv > space + 0.01; + } else { + let same = natural[&g.byte].iter().any(|r| (r.0 - g.adv).abs() < 1e-6); + assert!(same, "{composer:?}/{align:?}: a letter widened at {}", g.byte); + } + } + if line.last_in_para { + continue; + } + let source = &text[line.range.clone()]; + if source.trim_end().contains(' ') { + spaced += 1; + assert!((line.end_x - line.x1).abs() < 0.02, "{composer:?}/{align:?}: a line with a real space fills the measure"); + } else { + unspaced += 1; + let end = line.range.start + source.trim_end().len(); + let natural_width: f64 = natural.range(line.range.start..end).flat_map(|(_, m)| m).map(|m| m.0).sum(); + assert!((line.end_x - line.x0 - natural_width).abs() < 1e-6, "{composer:?}/{align:?}: no real space, no justification"); + assert!(line.end_x <= line.x1 + 0.02); + } + } + assert!(spaced > 0 && unspaced > 0 && grown, "{composer:?}/{align:?}: exercise both kinds of line ({spaced}/{unspaced}/{grown})"); + assert_eq!(doc.story(sid).unwrap().text, text, "layout never inserts spaces"); + } + } +} + +#[test] +fn a_real_space_added_by_the_author_justifies_the_line() { + for composer in [Composer::Paragraph, Composer::SingleLine] { + let width = word_width(composer) * 3.0; + for (text, fills) in [(format!("{WORD}\u{200b}{WORD}"), false), (format!("{WORD} {WORD}"), true)] { + let (doc, sid) = synthetic(&text, width, composer, Align::FullyJustified); + let cs = compose_story(&doc, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 1); + assert_eq!((lines[0].end_x - lines[0].x1).abs() < 0.02, fills, "{composer:?}: {text}"); + } + } +} + +/// Maximum glyph scaling, with a space taking the rest of a line far wider than the limits reach. +fn scaled_to_maximum(text: &str, family: Option<&str>, composer: Composer) -> (ComposedStory, ComposedStory) { + let build = |width: f64, align: Align| { + let (mut doc, sid) = fixture(text, width, composer, align); + if let Some(family) = family { + doc.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(family.into())); + } + glyph_scaling_limits(&mut doc, sid, text); + compose_story(&doc, sid, &ComposeOptions::default()) + }; + let natural = build(10000.0, Align::Left); + let width = all_lines(&natural)[0].end_x * 1.5; + (natural, build(width, Align::FullyJustified)) +} + +#[test] +fn glyph_scaling_stays_in_its_limits_and_real_spaces_take_the_rest() { + let text = format!("{WORD} {WORD}"); + for composer in [Composer::Paragraph, Composer::SingleLine] { + let (natural, cs) = scaled_to_maximum(&text, Some(synthetic_font()), composer); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 1); + assert!((lines[0].end_x - lines[0].x1).abs() < 0.02, "{composer:?}"); + let reference = glyph_metrics(&natural); + for (byte, glyphs) in glyph_metrics(&cs) { + for (g, original) in glyphs.iter().zip(&reference[&byte]) { + if text[byte..].starts_with(' ') { + assert!(g.0 > original.0 * 2.0, "{composer:?}: the space takes the rest"); + } else { + assert!((g.0 - original.0 * 1.04).abs() < 1e-6, "{composer:?}: {byte} past the maximum"); + assert!((g.1 - original.1 * 1.04).abs() < 1e-6, "{composer:?}: {byte} past the maximum"); + } + } + } + } +} + +#[test] +fn glyph_scaling_keeps_attached_marks_on_their_base() { + if !khmer_font_available() { + return; + } + let text = "ក្តី ខ្មែរ"; + let (natural, cs) = scaled_to_maximum(text, None, Composer::Paragraph); + let reference = cluster_geometry(&natural); + let metrics = glyph_metrics(&natural); + assert!(metrics.values().flatten().any(|m| m.0 == 0.0), "exercise a zero-advance mark"); + for (byte, cluster) in cluster_geometry(&cs) { + for (a, b) in cluster.iter().zip(&reference[&byte]) { + assert_eq!(a.0, b.0); + assert!((a.1 - b.1 * 1.04).abs() < 1e-6, "an attached glyph left its base at {byte}: {a:?} / {b:?}"); + assert!((a.2 - b.2).abs() < 1e-6, "glyph scaling is horizontal"); + } + } + for (byte, glyphs) in glyph_metrics(&cs) { + if !text[byte..].starts_with(' ') { + for (g, original) in glyphs.iter().zip(&metrics[&byte]) { + assert!((g.1 - original.1 * 1.04).abs() < 1e-6, "every glyph of a cluster scales together at {byte}"); + } + } + } +} + +#[test] +fn glyph_scaling_never_stretches_a_line_without_a_space_and_shrinks_only_to_its_minimum() { + let text = format!("{WORD}\u{200b}{WORD}"); + for composer in [Composer::Paragraph, Composer::SingleLine] { + for align in [Align::LeftJustified, Align::FullyJustified] { + let compose = |width: f64| { + let (mut doc, sid) = synthetic(&text, width, composer, align); + glyph_scaling_limits(&mut doc, sid, &text); + compose_story(&doc, sid, &ComposeOptions::default()) + }; + let natural_width = word_width(composer) * 2.0; + // Room to spare: no stretch, flush with the start of the line. + let roomy = compose(natural_width * 1.02); + let line = all_lines(&roomy)[0]; + assert!((line.end_x - natural_width).abs() < 1e-6, "{composer:?}/{align:?}: {} != {natural_width}", line.end_x); + // Slightly too wide, as the paragraph's last line too: glyph scaling fits it. + let tight = natural_width / 1.02; + let squeezed = compose(tight); + assert_eq!(squeezed.line_count(), 1, "{composer:?}/{align:?}"); + assert!((all_lines(&squeezed)[0].end_x - tight).abs() < 0.02, "{composer:?}/{align:?}: {}", all_lines(&squeezed)[0].end_x); + // Past the minimum it wraps at a boundary instead. + let narrow = natural_width / 1.1; + let wrapped = compose(narrow); + assert!(wrapped.line_count() >= 2, "{composer:?}/{align:?}"); + for line in all_lines(&wrapped) { + assert!(line.end_x <= narrow + 0.02, "{composer:?}/{align:?}: {} > {narrow}", line.end_x); + } + } + } +} + +#[test] +fn single_word_justification_places_a_khmer_line_without_a_space() { + let text = format!("{WORD}\u{200b}{WORD}"); + for composer in [Composer::Paragraph, Composer::SingleLine] { + let width = word_width(composer) * 3.0; + let natural = word_width(composer) * 2.0; + // The first line is not the last, so Single Word Justification decides its place. + let forced = format!("{text}\u{2028}{text}"); + for (setting, start) in + [(Align::Left, 0.0), (Align::FullyJustified, 0.0), (Align::Center, (width - natural) / 2.0), (Align::Right, width - natural)] + { + let (mut doc, sid) = synthetic(&forced, width, composer, Align::FullyJustified); + doc.story_mut(sid).unwrap().format_paras(0..forced.len(), |f| f.para.single_word_justify = Some(setting)); + let cs = compose_story(&doc, sid, &ComposeOptions::default()); + let line = all_lines(&cs)[0]; + assert!((line.glyphs[0].x - start).abs() < 1e-6, "{composer:?}/{setting:?}: {} != {start}", line.glyphs[0].x); + assert!((line.end_x - start - natural).abs() < 1e-6, "{composer:?}/{setting:?}: letters keep their width"); + } + } +} + +#[test] +fn letter_spacing_never_spreads_khmer_letters_when_a_real_space_justifies_the_line() { + // The first line has a real space and is justified; the forced break keeps it from being last. + let text = format!("{WORD} {WORD}\u{2028}{WORD}"); + for composer in [Composer::Paragraph, Composer::SingleLine] { + let width = word_width(composer) * 3.0; + let (natural, sid) = synthetic(&text, 100000.0, composer, Align::Left); + let natural = glyph_metrics(&compose_story(&natural, sid, &ComposeOptions::default())); + for align in [Align::LeftJustified, Align::FullyJustified] { + let (mut doc, sid) = synthetic(&text, width, composer, align); + doc.story_mut(sid).unwrap().format_paras(0..text.len(), |f| { + f.para.word_space_max = Some(1.0); + f.para.letter_space_desired = Some(0.2); + f.para.letter_space_max = Some(0.6); + }); + let cs = compose_story(&doc, sid, &ComposeOptions::default()); + let line = all_lines(&cs)[0]; + assert!((line.end_x - line.x1).abs() < 0.02, "{composer:?}/{align:?}: the spaced line fills the measure"); + for g in line.glyphs.iter().filter(|g| g.rendered_char != ' ' && g.visible) { + assert!( + natural[&g.byte].iter().any(|r| (r.0 - g.adv).abs() < 1e-6), + "{composer:?}/{align:?}: Khmer letters must never spread at {}", + g.byte + ); + } + } + } +} + +#[test] +fn zero_width_characters_in_other_scripts_keep_their_letter_spacing() { + let text = "alpha\u{200b}beta gamma\u{2060}delta"; + for composer in [Composer::Paragraph, Composer::SingleLine] { + let (mut doc, sid, _) = doc_with( + text, + Rect::new(0.0, 0.0, 1000.0, 1000.0), + ParaAttrs { composer: Some(composer), align: Some(Align::FullyJustified), ..Default::default() }, + ); + doc.story_mut(sid).unwrap().format_paras(0..text.len(), |f| { + f.para.letter_space_max = Some(0.5); + f.para.word_space_max = Some(1.0); + }); + let cs = compose_story(&doc, sid, &ComposeOptions::default()); + let line = all_lines(&cs)[0]; + let before_zwsp = line.glyphs.iter().find(|g| g.byte == 4).unwrap(); + let interior = line.glyphs.iter().find(|g| g.byte == 3).unwrap(); + assert!(before_zwsp.adv > interior.adv * 0.5, "{composer:?}: the gap before a ZWSP spreads as on any other letter"); + let (plain, sid2, _) = doc_with("a", Rect::new(0.0, 0.0, 1000.0, 1000.0), ParaAttrs::default()); + let natural = all_lines(&compose_story(&plain, sid2, &ComposeOptions::default()))[0].glyphs[0].adv; + assert!(before_zwsp.adv > natural + 0.01, "{composer:?}: {} <= {natural}", before_zwsp.adv); + } +} + +#[test] +fn word_count_uses_khmer_boundaries_and_leaves_other_text_alone() { + const PHRASE: &str = "ខ្ញុំស្រឡាញ់ភាសាខ្មែរ"; + for (text, words) in [("", 0), ("Hello world", 2), ("one - two", 3), ("日本語の文字", 1)] { + assert_eq!(word_count(text), words, "{text}: whitespace counting is unchanged"); + } + assert_eq!(word_count("ខ្មែរ\u{200b}ភាសា"), 2, "authored breaks work even without a dictionary"); + assert_eq!(word_count("ខ្មែរ ភាសា"), 2); + assert_eq!(word_count("ៗ។! ា្"), 0, "detached marks and signs are not words"); + if craft_khmer::data_available() { + assert_eq!(word_count(PHRASE), 4, "Khmer counts by word boundaries, not real spaces"); + assert_eq!(word_count(&format!("{PHRASE}\r\n{PHRASE}\n")), 8); + assert_eq!(word_count(&format!("Hello{PHRASE}world ១២៣ 42")), 8); + assert_eq!(word_count("អ្នកចម្រៀង ក្រុមហ៊ុន អ្វីៗ"), 4); + assert_eq!(word_count(PHRASE), 4, "a cached paragraph counts the same"); + } else { + assert_eq!(word_count(PHRASE), 1); + } +} diff --git a/crates/doc/src/attrs/language.rs b/crates/doc/src/attrs/language.rs index a116bf7546..9e1340ba73 100644 --- a/crates/doc/src/attrs/language.rs +++ b/crates/doc/src/attrs/language.rs @@ -83,6 +83,7 @@ pub fn language_tag(language: &str) -> Option<&'static str> { "turkish" => "tr", "japanese" => "ja", "korean" => "ko", + "khmer" | "cambodian" => "km", "arabic" => "ar", "persian" | "farsi" => "fa", "urdu" => "ur", diff --git a/crates/doc/src/attrs/tests.rs b/crates/doc/src/attrs/tests.rs index 78c62b77b0..5e65e7914b 100644 --- a/crates/doc/src/attrs/tests.rs +++ b/crates/doc/src/attrs/tests.rs @@ -79,6 +79,10 @@ fn language_names_map_to_bcp47_tags() { assert_eq!(language_tag("Georgian"), Some("ka")); assert_eq!(language_tag("Japanese"), Some("ja")); assert_eq!(language_tag("Korean"), Some("ko")); + assert_eq!(language_tag("Khmer"), Some("km")); + assert_eq!(language_tag("$ID/Khmer"), Some("km")); + assert_eq!(language_tag("Cambodian"), Some("km")); + assert_eq!(language_tag("km_KH"), Some("km-KH")); 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")); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 3d772b3057..99bfdb4ce9 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -199,16 +199,20 @@ pub fn shape_with_context( /// `features` as a run in `script` takes them. Contextual alternates are part of how Arabic joins /// (connecting strokes, tatweel and letter variants): InDesign keeps them in Arabic text with -/// Contextual Alternates off, so `calt` off is dropped there. +/// Contextual Alternates off, so `calt` off is dropped there. Khmer needs `clig` for +/// correct conjunct forms; legacy AAT fonts also put essential subscript substitutions +/// behind `liga`. A document's optional-ligature/tracking settings must not disable these. fn script_features(features: &[Feature], script: unicode_script::Script) -> std::borrow::Cow<'_, [Feature]> { use unicode_script::Script; - let calt = Tag::new(b"calt"); - let off = |f: &Feature| f.tag == calt && f.value == 0; - if script == Script::Arabic && features.iter().any(off) { - features.iter().filter(|f| !off(f)).copied().collect::>().into() - } else { - features.into() - } + let off = |f: &Feature| { + f.value == 0 + && match script { + Script::Arabic => f.tag == Tag::new(b"calt"), + Script::Khmer => f.tag == Tag::new(b"liga") || f.tag == Tag::new(b"clig"), + _ => false, + } + }; + if features.iter().any(off) { features.iter().filter(|f| !off(f)).copied().collect::>().into() } else { features.into() } } fn shape_dir( @@ -351,13 +355,16 @@ mod tests { use super::*; #[test] - fn arabic_keeps_contextual_alternates() { + fn complex_scripts_keep_required_shaping_features() { use unicode_script::Script; let off = feature("-calt").unwrap(); let liga = feature("-liga").unwrap(); - let tags = |s: Script| script_features(&[off, liga], s).iter().map(|f| (f.tag, f.value)).collect::>(); - assert_eq!(tags(Script::Arabic), vec![(liga.tag, 0)]); - assert_eq!(tags(Script::Latin), vec![(off.tag, 0), (liga.tag, 0)]); + let clig = feature("-clig").unwrap(); + let stylistic = feature("dlig").unwrap(); + let tags = |s: Script| script_features(&[off, liga, clig, stylistic], s).iter().map(|f| (f.tag, f.value)).collect::>(); + assert_eq!(tags(Script::Arabic), vec![(liga.tag, 0), (clig.tag, 0), (stylistic.tag, 1)]); + assert_eq!(tags(Script::Khmer), vec![(off.tag, 0), (stylistic.tag, 1)]); + assert_eq!(tags(Script::Latin), vec![(off.tag, 0), (liga.tag, 0), (clig.tag, 0), (stylistic.tag, 1)]); } #[test] diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index 36c23c1959..f01c8c4c4e 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -574,6 +574,7 @@ pub(crate) const LANGUAGES: &[&str] = &[ "Chinese: Simplified", "Chinese: Traditional", "Korean", + "Khmer", "Arabic", "Hebrew", "Esperanto", diff --git a/crates/ui-egui/src/story_editor.rs b/crates/ui-egui/src/story_editor.rs index b7d988416a..647115a219 100644 --- a/crates/ui-egui/src/story_editor.rs +++ b/crates/ui-egui/src/story_editor.rs @@ -53,7 +53,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let unit = st.doc.settings.vertical_units; let mut open = true; let mut edit: Option<(usize, usize, String)> = None; - let words = story.text.split_whitespace().count().to_string(); + let words = designcraft_compose::word_count(&story.text).to_string(); egui::Window::new(crate::i18n::tr(&app.ui.language, "Story Editor — {count} words").replace("{count}", &words)) .id(egui::Id::unique("story_editor")) .open(&mut open) diff --git a/docs/gaps.md b/docs/gaps.md index d74de3b04d..4e366feac0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; gap 16 shrunk: File › File Info; gap 16: Convert Variable to Text; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (optional Khmer composition; placed graphic layer-option preservation and explicit rendering warnings; gap 16 shrunk: File › File Info; gap 16: Convert Variable to Text; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. diff --git a/docs/khmer.md b/docs/khmer.md new file mode 100644 index 0000000000..606abc7614 --- /dev/null +++ b/docs/khmer.md @@ -0,0 +1,139 @@ +# Khmer writing support + +DesignCraft reuses the shared `craft-khmer` implementation proposed for +[WordCraft #5357](https://github.com/storytold/wordcraft/pull/5357). Its dependency +is pinned to [craft-libs #551](https://github.com/storytold/craft-libs/pull/551); +the optional source-data staging tool is in +[craft-i18n #122](https://github.com/storytold/craft-i18n/pull/122). These companion +contributions must land before official release integration. + +## User behavior + +Builds don't include the Khmer data yet; see [Build and data](#build-and-data). + +With the Khmer data included, unspaced Khmer receives dictionary/model word +boundaries for wrapping. The model can segment unknown or misspelled words, and it +never inserts spaces or normalizes document text. Spelling and proofreading are a +separate change. + +Authored U+200B zero-width spaces are optional wrap opportunities, not mandatory +newlines. U+2060 word joiners and No Break formatting prevent wrapping. Shaped +clusters, dependent vowels and repetition signs stay attached. Paragraph and +single-line composers share the same boundary source. The Story Editor's word count +uses these word boundaries for Khmer, not real spaces; without the data, only real and +authored zero-width spaces separate words. + +## Justification + +Justified Khmer follows the industry-standard behavior: + +- A Khmer word boundary, whether found by the model or authored as U+200B, only + permits a line break. It has no width, so justification never opens a gap there. +- Real word spaces (U+0020, U+00A0) take the length. They grow past Word Spacing's + maximum as far as the line needs, so a line with one real space can have one + very large gap. +- **Type > Justification…** Glyph Scaling applies to Khmer within its minimum and + maximum, with attached marks scaled along with their base. It stays at 100% by + default; a Khmer book style typically uses Word Spacing 100/100/100%, Letter + Spacing 0/0/0%, Glyph Scaling 96/100/104% and Single Word Justification Align Left. +- A line without a real word space is not stretched: it keeps its natural width. + To justify it, add a real space where one can be placed. Single Word Justification + Align Center and Align Right still position it; Full Justify cannot spread Khmer + letters, so it stays at the start of the line. If the line is slightly too wide, it + still shrinks down to Glyph Scaling's minimum to fit. +- Letter Spacing never reaches Khmer letters. + +Imported Ligatures Off or tracking settings cannot disable required Khmer +shaping: `clig` remains enabled, and legacy AAT fonts retain the `liga` +substitutions they use to form subscripts. This preserves document settings +while keeping coeng sequences shaped; Latin ligature controls are unchanged. +See the [Khmer OpenType shaping specification](https://learn.microsoft.com/en-us/typography/script-development/khmer). + +## Existing contributions + +[DesignCraft #516](https://github.com/storytold/designcraft/pull/516), by +visarutforthaipbs, addresses Thai, Lao, Khmer and Myanmar composition using ICU4X. +Its attached-glyph reasoning informed this integration. +This change supplies the Aksara-derived shared engine for Khmer; it does not +replace that contribution's work on the other three languages. Existing +[font fallback #504](https://github.com/storytold/designcraft/pull/504) supplies +installed Khmer faces to the interface. No fonts are added here. + +The shared core also credits WordCraft contributions by touchde (#3110) and +NguyenDuc2309 (#1579). Different compound boundaries can both be acceptable; +finer boundaries are useful for fitting lines, not evidence that every longer +compound is incorrectly segmented. + +## Build and data + +Builds contain no Khmer dictionaries, so only real and authored zero-width spaces +separate Khmer words. There is no runtime pack downloader in this change. + +Embedding the data is disabled for now. `craft-khmer` classifies the pack Download-only until +maintainers sign off on its licence classification and the policy for trained models and converted +configuration; see [PROVENANCE.md](https://github.com/storytold/craft-libs/blob/0b478944aa27a5bef1f51508c57bd236f272326d/crates/khmer/PROVENANCE.md). Until then a build that sets `CRAFT_KHMER_DIR` fails, with no bypass, so +build and test with `CRAFT_KHMER_DIR` and `CRAFT_KHMER_REQUIRED` unset. Staging with craft-i18n's +`language-packs` rejects the pack for the same reason. [`crates/khmer/DATA.md`](https://github.com/storytold/craft-libs/blob/0b478944aa27a5bef1f51508c57bd236f272326d/crates/khmer/DATA.md) describes the build for after that +approval, and the descriptions of data builds on this page apply from then on. + +Once enabled, `CRAFT_KHMER_DIR` must be an absolute path. Source +hashes and the upstream MIT notice are checked during the offline build; +`CRAFT_KHMER_REQUIRED=1` rejects missing data. The six losslessly compressed +resources total 714,343 bytes, with no dictionary pruning or model quantization. +SBBIC identifies its internal work, the SEAlang frequency lists and SIL NRSI's khmerlbdict +as the data's sources; their MIT notices are in `NOTICE`. The tagger was trained on internal +documents that SBBIC has permission to train with; those documents are not distributed. +This is resource size, not an installer-size measurement. Existing installed +Khmer fonts remain necessary unless the release's craft-fonts input supplies one. + +## Validation scope + +Validation covers actual composed glyph positions and line extents, not only +segmenter output. Targeted regressions cover authored boundaries, narrow columns, +formatting boundaries, repeated words and unchanged Latin text; the +justification regressions use a synthetic font and authored boundaries, so they need +neither the data pack nor a Khmer font. +The local corpus comes from five real, professionally typeset Khmer books: 3,331 +passages, with 302,429 model-counted word occurrences (302,022 after stripping +authored zero-width spaces). Sixteen configurations cover both composers, 180/300 pt +columns, authored separators retained/removed, and default Justification settings or +Word Spacing 100/100/100% with Glyph Scaling 96/100/104%. Across 565,353 composed +lines, checks found no breaks before dependent marks/repetition signs or after +COENG/WJ, no Khmer glyph wider than its scaling allows, no scaling outside its limits +and no attached glyph moved from its base. 345,681 of 345,689 interior lines with a +real word space fill the measure within 0.6 pt; the other eight are 0.91–3.21 pt +overruns at an authored forced line break in mixed text under the default settings +(none with the Glyph Scaling settings). The 166,072 interior lines without a real word +space keep their natural width, as designed: 15–19 pt short on average under the default +settings and 7–9 pt with Glyph Scaling, until an author adds a space. Tabs on them +still reach their stops, and Latin-only lines (URLs) keep the existing Single Word +Justification. The substitute font lacked some Latin/punctuation glyphs, but no Khmer glyphs. + +These are layout measurements, not linguistic accuracy ratings. It is not a +held-out linguistic gold standard, and model-counted word occurrences are not +an accuracy score. Private book text, source documents and reference PDFs stay outside +the repository. + +The local reference PDF and recovered source document have different edition dates. Matched +passages can support visual comparison; exact pagination and pixel equality +are not claimed. Missing source fonts or import differences must be distinguished +from Khmer segmentation/justification failures. Windows runtime acceptance and +release packaging remain separate work. + +## Known limits + +The caret at an unspaced line end follows the behavior DesignCraft already has for CJK +wraps: that offset is also the start of the next line, so End and clicks past the line +end draw the caret there. WordCraft's two-position caret is not part of this change. + +## Local CI baseline + +On Linux (Docker, Debian bookworm on arm64) with CI's apt packages and `craft-fonts` pin, +all workspace tests pass; with the Khmer pack added, the composition, engine and UI suites +pass too. This is a local reproduction, not a GitHub CI result. Formatting, PDF-dependency +policy, Clippy, translation reporting, doctests, asset attribution, crate layering and the +16-crate WASM check pass on macOS, and a Windows MSVC `cargo check` passes without running +the app. On macOS without `craft-fonts`, 13 UI tests fail on missing Arabic or +shortcut-symbol glyphs and on navigation/paste shortcuts; an untouched-main run at +`f3e35af8` fails exactly the same 13. The Khmer composition tests also pass without the +data pack. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0e77995d41..45521ad92e 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** minor (Esperanto text language; Hungarian interface catalog, bundled-font coverage and flyout regression checks; Swedish interface) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** minor (optional Khmer composition; Esperanto text language; Hungarian interface catalog, bundled-font coverage and flyout regression checks; Swedish interface) · **Target:** Adobe InDesign 2026 (21.6) Per-language status of the DesignCraft interface and of document typesetting in each script. Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md), @@ -93,7 +93,7 @@ eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, ## Document languages InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries). -DesignCraft hyphenates **English, Danish, Dutch, French, German (2006 reform), Greek, Italian, Norwegian, Portuguese and Spanish** and spell-checks **English** only. Text language +DesignCraft hyphenates **English, Danish, Dutch, French, German (2006 reform), Greek, Italian, Norwegian, Portuguese and Spanish** and spell-checks **English**. With its optional data pack, once maintainers approve embedding it, Khmer has word-based wrapping and justification at real word spaces with glyph scaling; this does not add a Khmer interface translation. See [khmer.md](khmer.md). Text language can be set per paragraph and character (Properties, Character panel) and drives quotes and locale shaping; Esperanto is offered too (not hyphenated yet, #1201). See [typography-parity.md](typography-parity.md). diff --git a/docs/roadmap.md b/docs/roadmap.md index ae162eedca..231c88fec5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DesignCraft roadmap (detail) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (independent parent page resizing, #1620; .indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (optional Khmer composition; independent parent page resizing, #1620; .indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6) Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in [target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 8e17cd2a97..4ff9ac96cd 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -1,6 +1,6 @@ # Typography and composition parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (German 2006 hyphenation; Optical Margin Alignment per story with a size in the Story panel, and Ignore Optical Margin in the Paragraph panel menu and paragraph styles, kept through IDML (#947); Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (optional Khmer composition; German 2006 hyphenation; Optical Margin Alignment per story with a size in the Story panel, and Ignore Optical Margin in the Paragraph panel menu and paragraph styles, kept through IDML (#947); Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6) Typesetting is what InDesign is bought for. A feature can exist and still set text differently from InDesign: different line breaks, different page count, overset where InDesign fits. This @@ -47,6 +47,7 @@ Fidelity: **unmeasured** unless stated. | First baseline | ascent, cap height, x-height, leading, fixed, min | ✓ | ✓ | | | Frame fit / overset | baseline of the last line must fit | descenders must fit | ~ | **differs** (#180): frames go overset where InDesign fits | | Baseline grid | document grid relative to the top of the page or each page's top margin; frame grids relative to the top of the page, top margin, top of frame or top inset | ✓ | ✓ | | +| Khmer horizontal justification | real word spaces and glyph scaling; lines without a real space stay natural | same, both composers; shared segmentation and authored ZWSP only choose line breaks; attached marks scale with their base | ~ | optional data pack; real document/PDF comparison, not exact pagination parity | | Vertical justification | top, centre, bottom, justify, paragraph spacing limit | ✓ | ✓ | | | Text in shaped frames | lines follow oval, polygon and freeform frame outlines, one inset from the outline | ✓ | ~ | concave shapes set one slot per line (the widest); vertical justification and paragraph shading use the frame's rectangle (#646) | | Columns, span / split columns | ✓ | ✓ | ✓ | split columns within a column (#295); text after a span flows below it (#276) |