From 4f8d230d70de78072d733369e892cece666de330 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 19:50:38 +0800 Subject: [PATCH 01/12] CJK-BASE: integrate font resolution and special-text preflight branches --- ROADMAP.md | 12 ++ crates/compose/src/cache.rs | 9 +- crates/compose/src/lib.rs | 37 +++- crates/compose/src/ruby.rs | 1 + crates/compose/src/shape.rs | 67 ++++++- crates/compose/src/tests.rs | 137 ++++++++++++++ crates/doc/src/edit.rs | 22 +++ crates/doc/src/lib.rs | 48 ++++- crates/doc/src/styles.rs | 6 + crates/engine/src/cmd/fonts.rs | 287 +++++++++++++++++++++++------ crates/engine/src/cmd/preflight.rs | 150 +++++++++++---- crates/engine/src/tests.rs | 28 +++ crates/fonts/src/fontdb.rs | 146 +++++++++++++-- crates/fonts/src/lib.rs | 59 ++++++ crates/idml/src/export.rs | 10 +- crates/idml/src/import.rs | 29 ++- crates/idml/src/tests.rs | 80 ++++++++ crates/pdf/src/export.rs | 44 ++++- crates/pdf/src/tests.rs | 29 +++ crates/render/src/lib.rs | 38 +++- crates/render/src/text.rs | 10 +- crates/ui-egui/src/dialogs.rs | 82 +++++++-- crates/ui-egui/src/lib.rs | 52 ++++++ crates/ui-egui/src/theme.rs | 29 ++- 24 files changed, 1246 insertions(+), 166 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index aa4c17396..e00fbc5da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,19 @@ # DesignCraft roadmap +- FONT7: replacement recommendations check the characters actually used; placeholder LastResort fonts and glyph-zero mappings cannot masquerade as glyph coverage or suppress missing-glyph diagnostics. + +- FONT6: localized and legacy family/style names share canonical resolution across shaping, inventory and replacement. Known compatible and script-based substitutes retain missing status; Find Font shows the resolved face and offers it as the replacement, while preflight reports unavailable styles. + +- FONT5: missing-glyph preflight inspects rendered characters in generated labels, including zero-length continuation glyphs, and identifies the owning page frame for nested text. + +- FONT4: generated note labels resolve composite character mappings and glyph fallback while retaining a single source anchor; composed glyphs retain the actual rendered character for diagnostics. + - 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. +- FONT3: opening documents with unavailable font families or styles automatically offers Find/Replace Font. The dialog supports repeated replacements, refreshed inventory and visible validation errors; native builds without craft-fonts use system CJK fonts for readable names. +- FONT2: font replacements validate available targets and resolve inherited formatting in nested text and empty paragraphs, with undo and composite component preservation. +- FONT1: composite-font references resolve by resource identity, retain literal display names, and export as object references. Font inventory and preflight share the character-selected component fonts, including nested notes and cells. - CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby/warichu remain unfinished. DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -23,6 +34,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. +- IDML1: embedded text frames and groups retain forward story references in body text, footnotes and table cells; anonymous composed stories retain anchored artwork for screen/PDF rendering. Filled compound outlines retain open contours; object-only inline lines use geometric extents, preflight distinguishes unplaced stories and locates embedded overflow at its page frame, and cyclic frame references are rejected. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 6d6b1b980..ca2fcd5e2 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -249,7 +249,14 @@ fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) let mut spreads = Vec::new(); for f in &story.frames { let Some(loc) = doc.find(*f) else { - sig.push(0); + // Embedded frames live in another story (possibly in a note/cell), + // so changing their geometry does not change this story's pointer. + use std::hash::{Hash, Hasher}; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + if let Some(item) = doc.anchored_item(*f) { + format!("{item:?}").hash(&mut hash); + } + sig.push(hash.finish() as usize); continue; }; if let Some(sp) = doc.spread(loc.spread) { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5a3056d8f..1ad7d0130 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -94,6 +94,8 @@ impl RunStyle { /// A positioned glyph. `x` is absolute in frame inner space; `y` is relative to the line baseline. #[derive(Clone, Debug)] pub struct PlacedGlyph { + /// Actual character drawn, including generated labels. + pub rendered_char: char, pub face: designcraft_fonts::FaceRef, pub gid: u32, pub x: f64, @@ -212,6 +214,8 @@ pub struct FrameText { pub struct PlacedObject { /// Index in the story's [`Story::objects`]. pub index: usize, + /// Source item retained for anonymous footnote and cell stories. + pub object: Arc, /// Top-left of the object in frame inner space. pub origin: Point, pub size: (f64, f64), @@ -328,13 +332,14 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { let Some(st) = doc.story(sid) else { return vec![] }; let mut out = Vec::with_capacity(st.frames.len()); for &fid in &st.frames { - let Some(loc) = doc.find(fid) else { continue }; - let Some(item) = doc.item_at(&loc) else { continue }; + let loc = doc.find(fid); + let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue }; let Some(tf) = item.text_frame() else { continue }; - let xf = doc.parent_xf(&loc) * item.xf; + let xf = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY) * item.xf; let inv = xf.inverse(); let mut exclusions = Vec::new(); - let spread = doc.spread(loc.spread); + let spread_ref = loc.as_ref().map(|l| l.spread); + let spread = spread_ref.and_then(|r| doc.spread(r)); if !tf.options.ignore_wrap && let Some(sp) = spread { @@ -352,14 +357,14 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { exclusions.push(Exclusion { rect: inner, mode: other.wrap.mode }); } } - let (page_name, page, left_page) = match (loc.spread, spread) { - (designcraft_doc::SpreadRef::Doc(si), Some(sp)) => { + let (page_name, page, left_page) = match (spread_ref, spread) { + (Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => { let c = item.bounds().center(); let pi = sp.page_at_x(c.x).unwrap_or(0); let abs = doc.first_page_of_spread(si) + pi; (Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left)) } - (designcraft_doc::SpreadRef::Parent(pi), Some(sp)) => { + (Some(designcraft_doc::SpreadRef::Parent(pi)), Some(sp)) => { let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into()); let _ = pi; (Some(prefix), None, false) @@ -367,7 +372,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { _ => (None, None, false), }; // The page under the frame and its margins, in inner space (bounding boxes). - let page_rect = match (loc.spread, spread) { + let page_rect = match (spread_ref, spread) { (_, Some(sp)) => sp .page_at_x(item.bounds().center().x) .and_then(|pi| sp.pages.get(pi)) @@ -882,8 +887,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let text_ascent = line_glyphs.iter().filter(|g| g.ch != designcraft_doc::OBJECT_MARK).map(|g| g.ascent).fold(0.0, f64::max); let text_ascent = if text_ascent > 0.0 { text_ascent } else { base_chars.size * 0.75 }; for g in ft.lines[li].glyphs.iter().filter(|g| g.len > 0) { - if let Some(o) = sub_objects.get(&g.byte) { - ft.objects.push(PlacedObject { index: o.index, origin: Point::ZERO, size: (o.w, o.h), line: li, x: g.x, text_ascent }); + if let Some(o) = sub_objects.get(&g.byte) + && let Some(source) = story.objects.get(o.index) + { + ft.objects.push(PlacedObject { + index: o.index, + object: Arc::clone(source), + origin: Point::ZERO, + size: (o.w, o.h), + line: li, + x: g.x, + text_ascent, + }); } } } @@ -1867,6 +1882,7 @@ fn tab_leader(tab: &Glyph, leader: &str, x: f64, w: f64, origin: f64, out: &mut while at + pw <= end + 0.01 && n < 2000 { for &(gid, adv) in &glyphs { out.push(PlacedGlyph { + rendered_char: '\t', face: tab.face, gid, x: at, @@ -1970,6 +1986,7 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph { || g.ch == shape::SOFT_HYPHEN || g.ch == shape::HIDDEN); PlacedGlyph { + rendered_char: g.rendered_char, face: g.face, gid: g.gid, x: x + g.dx, diff --git a/crates/compose/src/ruby.rs b/crates/compose/src/ruby.rs index cea4a5545..b39f11eef 100644 --- a/crates/compose/src/ruby.rs +++ b/crates/compose/src/ruby.rs @@ -22,6 +22,7 @@ fn set(base: &PlacedGlyph, text: &str, size: f64, base_size: f64) -> (Vec para_chars, }; let props = styles.resolve_char(para_chars, fmt); - let family = props.font_family.trim_start_matches("CompositeFont/"); - if let Some(font) = styles.composite_fonts.iter().find(|f| f.name == family) { + if let Some(font) = styles.composite_font(&props.font_family) { let mut start = a; let mut selected = None; for (offset, c) in story.text.get(a..b).unwrap_or("").char_indices() { @@ -301,7 +302,32 @@ pub(crate) fn shape_para( if m.starts_with(designcraft_doc::ENDNOTE_REF) { shape_run(db, &story.text, i..e, &eprops, eenv, estyle, sub, &mut glyphs); } else { - shape_run(db, &story.text, i..e, &rprops, renv, rstyle, sub, &mut glyphs); + if let Some(composite) = styles.composite_font(&rprops.font_family) { + let label = sub.notes.get(&i).map_or("#", String::as_str); + let begin = glyphs.len(); + let mut segments: Vec<(usize, usize, Option<&designcraft_doc::cjk::CompositeFontEntry>)> = Vec::new(); + for (at, c) in label.char_indices() { + let entry = composite.entry(c); + if let Some((_, end, _)) = segments.last_mut().filter(|(_, _, previous)| *previous == entry) { + *end = at + c.len_utf8(); + } else { + segments.push((at, at + c.len_utf8(), entry)); + } + } + for (a, b, entry) in segments { + let props = composite_props(db, &rprops, entry); + let style = table.intern(db, &props); + let env = table.env_for(auto_leading, style); + let face = db.face(&props.font_family, &props.font_style); + shape_segment(db, &story.text, i..e, label.get(a..b), &props, &face, env, style, &mut glyphs, sub.vertical); + } + for (index, glyph) in glyphs[begin..].iter_mut().enumerate() { + glyph.len = if index == 0 { e - i } else { 0 }; + glyph.no_break = true; + } + } else { + shape_run(db, &story.text, i..e, &rprops, renv, rstyle, sub, &mut glyphs); + } } k = e; } @@ -587,8 +613,10 @@ fn shape_run_raw( // Sits on the baseline like a (big) character. Some(y) => { g.adv = o.w; - g.ascent = g.ascent.max(o.h + y); - g.descent = g.descent.max(-y); + // The replacement character has no font ink. Its box alone + // supplies extents; adjacent text supplies its own font metrics. + g.ascent = (o.h + y).max(0.0); + g.descent = (-y).max(0.0); } // Pushes its line down by its height and spacing. None => g.ascent += o.h + o.space, @@ -779,6 +807,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty character_alignment: p.character_alignment, leading_model: p.leading_model, character_direction: p.character_direction, + rendered_char: ch, display_char: ch, bidi_offset: 0, bidi_end: 0, @@ -803,7 +832,32 @@ fn shape_segment( out: &mut Vec, vertical: bool, ) { - let _ = db; + // Generated labels need the same coverage fallback as ordinary text. Keep the + // original marker address and one carrier even when several faces draw the label. + if let Some(label) = replacement.filter(|_| auto_leading.glyph_fallback) { + let lang = designcraft_doc::language_tag(&p.language); + let mut segments: Vec<(usize, usize, Arc)> = Vec::new(); + for (at, c) in label.char_indices() { + let selected = if face.covers(c) { face.clone() } else { db.fallback_for(c, face.id(), lang).unwrap_or_else(|| face.clone()) }; + if let Some((_, end, previous)) = segments.last_mut().filter(|(_, _, previous)| previous.id() == selected.id()) { + *end = at + c.len_utf8(); + let _ = previous; + } else { + segments.push((at, at + c.len_utf8(), selected)); + } + } + if segments.iter().any(|(_, _, selected)| selected.id() != face.id()) { + let begin = out.len(); + for (a, b, selected) in segments { + shape_segment(db, text, range.clone(), label.get(a..b), p, &selected, auto_leading, style, out, vertical); + } + for (i, glyph) in out[begin..].iter_mut().enumerate() { + glyph.len = if i == 0 { range.len() } else { 0 }; + glyph.no_break = true; + } + return; + } + } let (size, k, ascent, descent, leading, cap, xh, shift) = metrics(face, p, auto_leading); let hs = p.h_scale; let tracking = p.tracking / 1000.0 * p.size; @@ -897,6 +951,7 @@ fn shape_segment( character_alignment: p.character_alignment, leading_model: p.leading_model, character_direction: p.character_direction, + rendered_char: src.get(sg.cluster..).and_then(|s| s.chars().next()).unwrap_or(ch), display_char: if replacement.is_some() && matches!(ch, '0'..='9' | '\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}') { src.chars().next().unwrap_or(ch) } else { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 549e7bccf..417adc61d 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1877,3 +1877,140 @@ fn vertical_lines_fit_the_em_box() { assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0); } } + +#[test] +fn embedded_text_frames_compose_and_footnotes_retain_object_sources() { + use designcraft_doc::{AnchorPosition, AnchoredObject}; + let (mut d, body, _) = doc_with("Body", Rect::new(20.0, 20.0, 400.0, 600.0), ParaAttrs::default()); + let (fid, child) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 40.0), d.default_layer(), "Embedded", ParaFormat::default()).unwrap(); + let item = d.item(fid).unwrap().clone(); + d.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != fid); + let story = d.story_mut(body).unwrap(); + story.insert_note(4, "Note", ParaFormat::default()); + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 })); + d.check().unwrap(); + let child_cs = compose_story(&d, child, &ComposeOptions::default()); + assert!(!child_cs.is_overset()); + assert!(!child_cs.frame(fid).unwrap().lines.is_empty()); + let cs = compose_story(&d, body, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let note = &cs.frames[0].notes[0]; + assert_eq!(note.text.story, StoryId(0)); + assert_eq!(note.text.frames[0].objects[0].object.item.id, fid); + + let cache = Cache::new(); + assert!(!cache.get(&d, child, None).is_overset()); + let note = Arc::make_mut(&mut d.story_mut(body).unwrap().notes[0]); + let anchor = Arc::make_mut(&mut note.text.objects[0]); + let original_path = anchor.item.path.clone(); + anchor.item.path = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 1.0)); + assert!(cache.get(&d, child, None).is_overset(), "embedded frame geometry must invalidate cached composition"); + Arc::make_mut(&mut Arc::make_mut(&mut d.story_mut(body).unwrap().notes[0]).text.objects[0]).item.path = original_path; + + // A reference back into the owning story must be rejected before rendering. + let mut cycle = d.anchored_item(fid).unwrap().clone(); + cycle.id = ItemId(d.alloc()); + cycle.text_frame_mut().unwrap().story = body; + d.story_mut(body).unwrap().frames.push(cycle.id); + d.story_mut(child).unwrap().insert_object(0, AnchoredObject::new(cycle, AnchorPosition::Inline { y_offset: 0.0 })); + assert!(d.check().unwrap_err().to_string().contains("cyclic")); +} + +#[test] +fn inline_object_only_line_does_not_reserve_phantom_font_descent() { + use designcraft_doc::{AnchorPosition, AnchoredObject, Item, Shape}; + let (mut d, sid, _) = doc_with("", Rect::new(0.0, 0.0, 10.0, 15.0), ParaAttrs::default()); + let item = Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 7.0, 9.0))); + d.story_mut(sid).unwrap().insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 5.0 })); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_eq!(cs.frames[0].objects.len(), 1); + assert_eq!(cs.frames[0].lines[0].descent, 0.0); + // Genuine geometric overflow must still be reported. + d.story_mut(sid).unwrap().objects[0] = + Arc::new(AnchoredObject::new(cs.frames[0].objects[0].object.item.clone(), AnchorPosition::Inline { y_offset: 7.0 })); + assert!(compose_story(&d, sid, &ComposeOptions::default()).is_overset()); +} + +#[test] +fn composite_metrics_and_character_mappings_apply_inside_footnotes() { + use designcraft_doc::cjk::{CompositeFont, CompositeFontEntry}; + let (mut d, sid, _) = doc_with("Body", Rect::new(0.0, 0.0, 300.0, 300.0), ParaAttrs::default()); + d.styles_mut().composite_fonts.push(CompositeFont { + name: "Mixed".into(), + entries: vec![ + CompositeFontEntry { family: "Source Serif 4".into(), ..Default::default() }, + CompositeFontEntry { + characters: "12".into(), + family: "Source Sans 3".into(), + relative_size: 0.8, + horizontal_scale: 0.9, + vertical_scale: 1.1, + baseline_shift: 0.1, + scale_option: false, + ..Default::default() + }, + CompositeFontEntry { characters: "2".into(), family: "Inter".into(), ..Default::default() }, + ], + }); + let story = d.story_mut(sid).unwrap(); + story.insert_note(4, "A12", ParaFormat::default()); + let note = Arc::make_mut(&mut story.notes[0]); + note.text.format_chars(0..3, |f| f.over.font_family = Some("CompositeFont/Mixed".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let note = &cs.frames[0].notes[0]; + let glyphs = ¬e.text.frames[0].lines[0].glyphs; + let a = glyphs.iter().find(|g| g.byte == 0 && g.len > 0).unwrap(); + let one = glyphs.iter().find(|g| g.byte == 1 && g.len > 0).unwrap(); + let two = glyphs.iter().find(|g| g.byte == 2 && g.len > 0).unwrap(); + assert_eq!(a.face.family, "Source Serif 4"); + assert_eq!(one.face.family, "Source Sans 3"); + assert_eq!(two.face.family, "Inter"); + let style = ¬e.text.styles[one.style as usize]; + assert!((style.size - 9.6).abs() < 1e-8); + assert!(!note.text.styles.iter().any(|s| s.missing_font)); +} + +#[test] +fn generated_note_labels_resolve_composite_components_and_keep_one_anchor() { + use designcraft_doc::cjk::{CompositeFont, CompositeFontEntry}; + let (mut d, sid, _) = doc_with("Body", Rect::new(0.0, 0.0, 300.0, 300.0), ParaAttrs::default()); + d.styles_mut().composite_fonts.push(CompositeFont { + name: "Generated Mixed".into(), + entries: vec![ + CompositeFontEntry { family: "Source Serif 4".into(), ..Default::default() }, + CompositeFontEntry { characters: "[]".into(), family: "Inter".into(), relative_size: 0.8, ..Default::default() }, + ], + }); + d.footnote_options.affix_in = designcraft_doc::notes::AffixIn::Both; + d.footnote_options.prefix = "[".into(); + d.footnote_options.suffix = "]".into(); + let story = d.story_mut(sid).unwrap(); + story.insert_note(4, "Note", ParaFormat::default()); + story.format_chars(4..7, |f| f.over.font_family = Some("Generated Mixed".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let glyphs: Vec<_> = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.byte == 4).collect(); + assert_eq!(glyphs.iter().filter(|g| g.len > 0).count(), 1); + assert_eq!(glyphs.iter().map(|g| g.rendered_char).collect::(), "[1]"); + assert!(glyphs.iter().all(|g| g.gid != 0)); + assert!(glyphs.iter().filter(|g| matches!(g.rendered_char, '[' | ']')).all(|g| g.face.family == "Inter")); + assert_eq!(glyphs.iter().find(|g| g.rendered_char == '1').unwrap().face.family, "Source Serif 4"); + assert_eq!(cs.frames[0].notes.len(), 1); +} + +#[test] +fn generated_note_labels_use_coverage_fallback() { + let font = designcraft_fonts::testing::font_with("Generated Label Coverage", &['\u{E888}']).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + let (mut d, sid, _) = doc_with("Body", Rect::new(0.0, 0.0, 300.0, 300.0), ParaAttrs::default()); + d.footnote_options.affix_in = designcraft_doc::notes::AffixIn::Both; + d.footnote_options.prefix = "\u{E888}".into(); + d.footnote_options.suffix = "".into(); + d.settings.glyph_fallback = true; + d.story_mut(sid).unwrap().insert_note(4, "Note", ParaFormat::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let glyph = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).find(|g| g.rendered_char == '\u{E888}').unwrap(); + assert_ne!(glyph.gid, 0); + assert_eq!(glyph.face.family, "Generated Label Coverage"); +} diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 188f514d2..97728c875 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -89,6 +89,28 @@ impl Document { self.item_at(&self.find(id)?) } + /// Items embedded in stories (including footnotes and table cells), with their + /// owning top-level story. Does not follow text-frame references between stories. + pub fn anchored_items(&self) -> Vec<(StoryId, &Item)> { + let mut out = Vec::new(); + let mut stories: Vec<_> = self.stories.iter().map(|(id, story)| (*id, story.as_ref())).collect(); + while let Some((owner, story)) = stories.pop() { + stories.extend(story.notes.iter().chain(&story.endnotes).map(|n| (owner, &n.text))); + stories.extend(story.tables.values().flat_map(|t| t.cells.iter().map(|c| (owner, &c.text)))); + let mut items: Vec<&Item> = story.objects.iter().map(|o| &o.item).collect(); + while let Some(item) = items.pop() { + out.push((owner, item)); + items.extend(item.children().iter().map(AsRef::as_ref)); + } + } + out + } + + /// An embedded item is not a spread selection location (`find`). + pub fn anchored_item(&self, id: ItemId) -> Option<&Item> { + self.anchored_items().into_iter().find_map(|(_, item)| (item.id == id).then_some(item)) + } + pub fn item_mut_at(&mut self, loc: &ItemLoc) -> Option<&mut Item> { let sp = self.spread_mut(loc.spread)?; let mut it: &mut Item = Arc::make_mut(sp.items.get_mut(*loc.path.first()?)?); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c4b69e86d..4ac3b9a5d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -729,8 +729,8 @@ impl Document { return Err(DocError::Invalid(format!("story key {sid} != id {}", st.id))); } for f in &st.frames { - let item = self.find(*f).ok_or(DocError::NoItem(*f))?; - match self.item_at(&item).and_then(|i| i.text_frame().map(|t| t.story)) { + let item = self.item(*f).or_else(|| self.anchored_item(*f)).ok_or(DocError::NoItem(*f))?; + match item.text_frame().map(|t| t.story) { Some(s) if s == *sid => {} other => return Err(DocError::Invalid(format!("frame {f} in story {sid} points at {other:?}"))), } @@ -752,6 +752,50 @@ impl Document { } } } + let embedded = self.anchored_items(); + let mut edges: std::collections::HashMap> = std::collections::HashMap::new(); + for (owner, item) in embedded { + if !ids.insert(item.id.0) { + return Err(DocError::Invalid(format!("duplicate item id {}", item.id))); + } + if let Some(tf) = item.text_frame() { + if !self.stories.get(&tf.story).is_some_and(|s| s.frames.contains(&item.id)) { + return Err(DocError::Invalid(format!("embedded text frame {} not in its story's thread", item.id))); + } + edges.entry(owner).or_default().push(tf.story); + } + } + // Reject cycles and excessive nesting before renderers follow embedded frames. + fn visit( + id: StoryId, + edges: &std::collections::HashMap>, + path: &mut Vec, + done: &mut std::collections::HashMap, + ) -> Result { + if path.contains(&id) || path.len() >= 64 { + return Err(DocError::Invalid("cyclic or excessively nested anchored text frames".into())); + } + if let Some(&height) = done.get(&id) { + return Ok(height); + } + path.push(id); + let mut height = 1; + if let Some(children) = edges.get(&id) { + for &child in children { + height = height.max(1 + visit(child, edges, path, done)?); + } + } + path.pop(); + if height > 64 { + return Err(DocError::Invalid("excessively nested anchored text frames".into())); + } + done.insert(id, height); + Ok(height) + } + let mut done = std::collections::HashMap::new(); + for &id in edges.keys() { + visit(id, &edges, &mut Vec::new(), &mut done)?; + } if self.next_id < ids.iter().copied().max().unwrap_or(0) { return Err(DocError::Invalid("next_id behind existing ids".into())); } diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 478cf0452..d6cd3cbfb 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -94,6 +94,12 @@ pub struct ExportTag { } impl Styles { + /// Resolve either a display name or a normalized IDML composite-font reference. + pub fn composite_font(&self, family: &str) -> Option<&crate::cjk::CompositeFont> { + let name = family.strip_prefix("CompositeFont/").unwrap_or(family); + self.composite_fonts.iter().find(|f| f.name == name) + } + /// Export tagging of a paragraph (`character` false) or character style. pub fn export_tag(&self, name: &str, character: bool) -> Option<&ExportTag> { self.export_tags.get(&format!("{}:{name}", if character { "c" } else { "p" })) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index ef7eb9313..83acd685b 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -13,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, source: bundled|installed|document|added (null when missing)}] (missing first)", has_doc, |s, _| { + cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle, replacementFamily, replacementStyle}] (missing first)", has_doc, |s, _| { Ok(Value::Array(list(&s.doc()?.doc))) }), cmd!( @@ -30,22 +30,38 @@ pub fn specs() -> Vec { /// Every story's text, including table cells and footnotes. fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) { - for st in d.stories.values() { + let mut pending: Vec<&Story> = d.stories.values().map(AsRef::as_ref).collect(); + while let Some(st) = pending.pop() { f(st); - for t in st.tables.values() { - for c in &t.cells { - f(&c.text); - } - } - for n in &st.notes { - f(&n.text); - } + pending.extend(st.tables.values().flat_map(|t| t.cells.iter().map(|c| &c.text))); + pending.extend(st.notes.iter().chain(&st.endnotes).map(|n| &n.text)); } } +/// Effective document font use shared by the command layer and preflight. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct UsedFont { + pub family: String, + pub style: String, + pub characters: usize, + pub missing: bool, + pub style_missing: bool, + pub source: Option<&'static str>, + pub match_status: &'static str, + pub resolved_family: String, + pub resolved_style: String, + pub replacement_family: String, + pub replacement_style: String, +} + fn list(d: &Document) -> Vec { + used_fonts(d).into_iter().map(|font| json!(font)).collect() +} + +pub(super) fn used_fonts(d: &Document) -> Vec { let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); - let mut used: BTreeMap<(String, String), usize> = BTreeMap::new(); + let mut used: BTreeMap<(String, String), (usize, std::collections::BTreeSet)> = BTreeMap::new(); for_each_story(d, &mut |st| { let ranges = st.para_ranges(); for (pi, r) in ranges.iter().enumerate() { @@ -56,38 +72,60 @@ fn list(d: &Document) -> Vec { continue; } let p = d.styles.resolve_char(&base, fmt); - if let Some(f) = d.styles.composite_fonts.iter().find(|f| f.name == p.font_family.trim_start_matches("CompositeFont/")) { + if let Some(f) = d.styles.composite_font(&p.font_family) { let text = st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or(""); for c in text.chars() { if let Some(e) = f.entry(c) { - *used.entry((e.family.clone(), e.style.clone())).or_default() += 1; + let entry = used.entry((e.family.clone(), e.style.clone())).or_default(); + entry.0 += 1; + entry.1.insert(c); } } } else { - *used.entry((p.font_family, p.font_style)).or_default() += n; + let entry = used.entry((p.font_family, p.font_style)).or_default(); + entry.0 += n; + entry.1.extend(st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or("").chars()); } } } }); - let mut out: Vec<(bool, Value)> = used + let mut out: Vec = used .into_iter() - .map(|((family, style), n)| { + .map(|((family, style), (n, characters))| { let missing = !db.has_family(&family); - let style_missing = !missing && !db.styles(&family).iter().any(|s| s.eq_ignore_ascii_case(&style)); + let style_missing = !missing && !db.has_style(&family, &style); + let resolved = db.face(&family, &style); + let characters: Vec = characters.into_iter().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + let replacement = db.replacement_face(&family, &style, &characters); 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}), - ) + UsedFont { + family, + style, + characters: n, + missing, + style_missing, + source, + match_status: if missing { + "missing" + } else if style_missing { + "styleSubstitute" + } else { + "exact" + }, + resolved_family: resolved.family.clone(), + resolved_style: resolved.style.clone(), + replacement_family: replacement.family.clone(), + replacement_style: replacement.style.clone(), + } }) .collect(); - out.sort_by_key(|(m, _)| !*m); - out.into_iter().map(|(_, v)| v).collect() + out.sort_by_key(|font| !(font.missing || font.style_missing)); + out } /// Replace the font in a sparse attribute set (only where the family is set); true when it changed. @@ -109,9 +147,66 @@ fn replace(s: &mut Session, p: &Value) -> Result { let style = str_param(p, "style").map(str::to_string); let to_family = str_param(p, "toFamily").ok_or_else(|| bad("font.replace", "missing toFamily"))?.to_string(); let to_style = str_param(p, "toStyle").map(str::to_string); + let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope); + if family.trim().is_empty() || to_family.trim().is_empty() || !db.has_family(&to_family) { + return Err(bad("font.replace", "Choose an available replacement font family")); + } + if let Some(style) = &to_style + && !db.has_style(&to_family, style) + { + return Err(bad("font.replace", "Choose an available replacement font style")); + } + let resolved = db.face(&to_family, to_style.as_deref().or(style.as_deref()).unwrap_or("Regular")); + if resolved.is_placeholder() { + return Err(bad("font.replace", "A placeholder font cannot replace document text")); + } + let to_family = resolved.family.clone(); + let to_style = (to_style.is_some() || style.is_some()).then(|| resolved.style.clone()); let styles_too = p.get("redefineStyles").and_then(Value::as_bool).unwrap_or(true); s.edit(|d, _| { let mut changed = 0usize; + // Resolve text against the original styles before redefining them. Font family and + // style can be inherited independently through paragraph and character formatting. + let original_styles = d.styles.clone(); + let ids: Vec<_> = d.stories.keys().copied().collect(); + for sid in ids { + let Some(st) = d.story_mut(sid) else { continue }; + let mut pending = vec![st]; + while let Some(st) = pending.pop() { + let mut ranges = Vec::new(); + for (pi, paragraph) in st.para_ranges().iter().enumerate() { + let Some(format) = st.paras.get(pi) else { continue }; + let (_, base) = original_styles.resolve_para(format); + for (run, format) in st.runs() { + let start = run.start.max(paragraph.start); + // An empty paragraph still uses its paragraph-break character's font. + let end = if paragraph.is_empty() && run.contains(¶graph.start) { + st.text.get(start..).and_then(|tail| tail.chars().next()).map_or(start, |c| start.saturating_add(c.len_utf8())) + } else { + run.end.min(paragraph.end) + }; + if start >= end { + continue; + } + let effective = original_styles.resolve_char(&base, format); + if effective.font_family.eq_ignore_ascii_case(&family) + && style.as_ref().is_none_or(|s| effective.font_style.eq_ignore_ascii_case(s)) + { + ranges.push((start..end, db.face(&to_family, to_style.as_deref().unwrap_or(&effective.font_style)).style.clone())); + } + } + } + for (range, replacement_style) in ranges { + st.format_chars(range, |format| { + format.over.font_family = Some(to_family.clone()); + format.over.font_style = Some(replacement_style.clone()); + }); + changed += 1; + } + pending.extend(st.tables.values_mut().flat_map(|t| Arc::make_mut(t).cells.iter_mut().map(|c| &mut c.text))); + pending.extend(st.notes.iter_mut().chain(&mut st.endnotes).map(|n| &mut Arc::make_mut(n).text)); + } + } if styles_too { let st = d.styles_mut(); for ps in &mut st.paragraph { @@ -124,40 +219,12 @@ fn replace(s: &mut Session, p: &Value) -> Result { for e in &mut f.entries { if e.family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|s| e.style.eq_ignore_ascii_case(s)) { e.family.clone_from(&to_family); - if let Some(s) = &to_style { - e.style.clone_from(s); - } + e.style = db.face(&to_family, to_style.as_deref().unwrap_or(&e.style)).style.clone(); changed += 1; } } } } - // Local formatting in every story (cells and footnotes too). - let fix = |st: &mut Story, changed: &mut usize| { - let mut any = false; - for r in &mut st.chars { - if swap(&mut r.format.over, &family, style.as_deref(), &to_family, to_style.as_deref()) { - any = true; - *changed += 1; - } - } - if any { - st.rev += 1; - } - }; - let ids: Vec<_> = d.stories.keys().copied().collect(); - for sid in ids { - let Some(st) = d.story_mut(sid) else { continue }; - fix(st, &mut changed); - for t in st.tables.values_mut() { - for c in &mut Arc::make_mut(t).cells { - fix(&mut c.text, &mut changed); - } - } - for n in &mut st.notes { - fix(&mut Arc::make_mut(n).text, &mut changed); - } - } Ok(json!({"changed": changed})) }) } @@ -166,6 +233,27 @@ fn replace(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + #[test] + fn replacement_resolves_inherited_family_and_style_and_rejects_missing_targets() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [0,0,200,200], "content":"text", "text":"Inherited\n\nEnd"})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let sid = *d.stories.keys().next().unwrap(); + let story = d.story_mut(sid).unwrap(); + for para in &mut story.paras { + para.chars.font_family = Some("Unavailable Family".into()); + } + let before = s.execute("font.list", &json!({})).unwrap(); + let style = before[0]["style"].clone(); + assert!(s.execute("font.replace", &json!({"family":"Unavailable Family", "toFamily":"Also Missing"})).is_err()); + assert_eq!(before, s.execute("font.list", &json!({})).unwrap()); + s.execute("font.replace", &json!({"family":"Unavailable Family", "style":style, "toFamily":"Source Sans 3", "toStyle":"Regular"})).unwrap(); + assert!(s.execute("font.list", &json!({})).unwrap().as_array().unwrap().iter().all(|f| f["missing"] == false)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(before, s.execute("font.list", &json!({})).unwrap()); + } + #[test] fn find_and_replace_font() { let mut s = Session::new(); @@ -191,3 +279,98 @@ mod tests { assert_eq!(s.doc().unwrap().doc.styles.para("Odd").unwrap().chars.font_family.as_deref(), Some(avail.as_str())); } } + +#[cfg(test)] +mod composite_tests { + use super::*; + use designcraft_doc::{ + ParaFormat, + cjk::{CompositeFont, CompositeFontEntry}, + }; + + #[test] + fn inventory_resolves_composites_in_nested_cell_notes() { + use designcraft_doc::{StoryId, Table, build::NewDocument}; + let mut d = Document::new(&NewDocument::default()); + d.styles_mut().composite_fonts.push(CompositeFont { + name: "Mixed".into(), + entries: vec![ + CompositeFontEntry { family: "Source Serif 4".into(), ..Default::default() }, + CompositeFontEntry { characters: "1".into(), family: "Source Sans 3".into(), ..Default::default() }, + ], + }); + let mut story = Story::new(StoryId(d.alloc())); + let mut table = Table::new(1, 1, 1, 0, 0, 100.0); + let cell = &mut table.cells[0].text; + cell.insert_note(0, "1", ParaFormat::default()); + Arc::make_mut(&mut cell.notes[0]).text.format_chars(0..1, |f| f.over.font_family = Some("Mixed".into())); + story.insert_table(0, table); + d.stories.insert(story.id, Arc::new(story)); + let fonts = list(&d); + assert!(fonts.iter().any(|f| f["family"] == "Source Sans 3" && f["characters"] == 1)); + assert!(!fonts.iter().any(|f| f["family"] == "Mixed")); + } + + #[test] + fn preflight_and_font_list_follow_used_composite_entries_in_notes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [20,20,400,600], "content":"text", "text":"Body"})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.styles_mut().composite_fonts.push(CompositeFont { + name: "Mixed".into(), + entries: vec![ + CompositeFontEntry { family: "Source Serif 4".into(), ..Default::default() }, + CompositeFontEntry { characters: "1".into(), family: "Missing Note Digits".into(), ..Default::default() }, + CompositeFontEntry { characters: "2".into(), family: "Unused Missing Digits".into(), ..Default::default() }, + ], + }); + let sid = *d.stories.keys().next().unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert_note(4, "A1", ParaFormat::default()); + Arc::make_mut(&mut st.notes[0]).text.format_chars(0..2, |f| f.over.font_family = Some("CompositeFont/Mixed".into())); + let list = s.execute("font.list", &json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|f| f["family"] == "Missing Note Digits" && f["characters"] == 1)); + assert!(!list.to_string().contains("Unused Missing Digits")); + let issues = s.execute("preflight.run", &json!({})).unwrap(); + let fonts: Vec<_> = issues["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "missingFont").collect(); + assert_eq!(fonts.len(), 1, "{issues}"); + assert!(fonts[0]["message"].as_str().unwrap().contains("Missing Note Digits")); + s.execute("font.replace", &json!({"family":"Missing Note Digits", "toFamily":"Source Sans 3", "toStyle":"Regular"})).unwrap(); + let issues = s.execute("preflight.run", &json!({})).unwrap(); + assert!(issues["issues"].as_array().unwrap().iter().all(|i| i["kind"] != "missingFont")); + assert_eq!(s.doc().unwrap().doc.styles.composite_fonts[0].entries[1].characters, "1"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.composite_fonts[0].entries[1].family, "Missing Note Digits"); + } +} + +#[cfg(test)] +mod resolution_tests { + use super::*; + + #[test] + fn inventory_distinguishes_missing_styles_and_replacement_chooses_an_available_style() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect":[20,20,300,400],"content":"text","text":"Body"})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let sid = *d.stories.keys().next().unwrap(); + d.story_mut(sid).unwrap().format_chars(0..4, |f| { + f.over.font_family = Some("Source Sans 3".into()); + f.over.font_style = Some("Unavailable Style".into()); + }); + let before = s.execute("font.list", &json!({})).unwrap(); + assert_eq!(before[0]["matchStatus"], "styleSubstitute"); + assert_eq!(before[0]["missing"], false); + assert_eq!(before[0]["styleMissing"], true); + assert_eq!(before[0]["resolvedStyle"], "Regular"); + let preflight = s.execute("preflight.run", &json!({})).unwrap(); + assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "missingFontStyle")); + s.execute("font.replace", &json!({"family":"Source Sans 3","toFamily":"Source Serif 4"})).unwrap(); + let after = s.execute("font.list", &json!({})).unwrap(); + assert_eq!(after[0]["matchStatus"], "exact"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(before, s.execute("font.list", &json!({})).unwrap()); + } +} diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 3fc061d7d..25b9d45fd 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -38,56 +38,105 @@ fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option { /// Characters a missing-glyph report names before "and N more". const MISSING_GLYPHS_LISTED: usize = 20; -/// Add the characters drawn as their font's missing glyph (.notdef) in `cs`, composed from -/// `text` (table cells and footnotes included), to `out` by face, each once. Text in a missing -/// font is left to the missing-font report; generated text (list labels, page numbers) and -/// markers have no character of their own to name. -fn missing_glyphs(cs: &ComposedStory, text: &str, depth: usize, out: &mut Vec<(FaceRef, Vec)>) { - // Cells and footnotes hold composed stories of their own; tables nest only so deep. +/// Inspect rendered characters, including zero-length glyphs generated by a source marker. +/// Nested notes and cells report the owning page frame, not their synthetic frame zero. +fn missing_glyphs( + cs: &ComposedStory, + owner: Option, + depth: usize, + out: &mut Vec<(FaceRef, Vec, designcraft_doc::ItemId)>, +) { if depth > 16 { return; } for f in &cs.frames { - for g in f.lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.visible && g.gid == 0 && g.len > 0) { - if cs.styles.get(g.style as usize).is_none_or(|st| st.missing_font) { - continue; - } - let Some(c) = text.get(g.byte..).and_then(|t| t.chars().next()) else { continue }; + let owner = owner.unwrap_or(f.frame); + for g in f.lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.visible && (g.gid == 0 || g.face.is_placeholder())) { + let c = g.rendered_char; if c.is_control() || c.is_whitespace() || ('\u{E000}'..='\u{E1FF}').contains(&c) { continue; } - match out.iter_mut().find(|(face, _)| *face == g.face) { - Some((_, chars)) if !chars.contains(&c) => chars.push(c), + match out.iter_mut().find(|(face, _, item)| *face == g.face && *item == owner) { + Some((_, chars, _)) if !chars.contains(&c) => chars.push(c), Some(_) => {} - None => out.push((g.face, vec![c])), + None => out.push((g.face, vec![c], owner)), } } for c in f.tables.iter().flat_map(|t| &t.cells) { - missing_glyphs(&c.text, &c.source, depth + 1, out); + missing_glyphs(&c.text, Some(owner), depth + 1, out); } for n in &f.notes { - missing_glyphs(&n.text, &n.source, depth + 1, out); + missing_glyphs(&n.text, Some(owner), depth + 1, out); } } } +/// Locate an embedded frame at the outer page frame that actually renders it. +fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(designcraft_doc::ItemId, Option)> { + let d = &s.active()?.doc; + if let Some(loc) = d.find(target) { + return Some((target, d.item(target).and_then(|it| page_of(d, loc.spread, it)))); + } + fn contains(root: &designcraft_compose::FrameText, target: designcraft_doc::ItemId) -> bool { + let mut frames = vec![root]; + while let Some(frame) = frames.pop() { + for object in &frame.objects { + let mut found = false; + object.object.item.walk(&mut |it| { + found |= it.id == target; + }); + if found { + return true; + } + } + frames.extend(frame.notes.iter().flat_map(|n| n.text.frames.iter())); + frames.extend(frame.tables.iter().flat_map(|t| t.cells.iter().flat_map(|c| c.text.frames.iter()))); + } + false + } + let mut current = target; + let mut seen = std::collections::HashSet::new(); + let embedded = d.anchored_items(); + while seen.insert(current) { + let owner = embedded.iter().find_map(|(owner, it)| (it.id == current).then_some(*owner))?; + let cs = s.cache.get(d, owner, None); + // Search each host frame separately, including objects in its notes and cells. + let frame = cs.frames.iter().find(|f| contains(f, current))?; + current = frame.frame; + if let Some(loc) = d.find(current) { + return Some((current, d.item(current).and_then(|it| page_of(d, loc.spread, it)))); + } + } + None +} + pub fn check(s: &Session, min_ppi: f64) -> Vec { let Some(st) = s.active() else { return vec![] }; let d = &st.doc; let mut out = Vec::new(); // Stories: overset and fonts. - 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(); - let mut missing: Vec<(FaceRef, Vec)> = Vec::new(); + let mut missing: Vec<(FaceRef, Vec, designcraft_doc::ItemId)> = Vec::new(); for story in d.stories.values() { let cs = s.cache.get(d, story.id, None); - missing_glyphs(&cs, &story.text, 0, &mut missing); + missing_glyphs(&cs, None, 0, &mut missing); if cs.is_overset() { let last = story.frames.last().copied(); - let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it)); - let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count(); - out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page }); + let location = last.and_then(|f| overset_location(s, f)); + let n = story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count(); + let unplaced = story.frames.is_empty(); + out.push(Issue { + severity: if unplaced { "warning" } else { "error" }, + kind: if unplaced { "unplacedStory" } else { "overset" }, + message: if unplaced { + format!("Unplaced story {}: {n} characters have no text frame", story.id) + } else { + format!("Overset text: {n} characters") + }, + item: location.map(|(item, _)| item.0).or(last.map(|i| i.0)), + page: location.and_then(|(_, page)| page), + }); } let ranges = story.para_ranges(); for (pi, pf) in story.paras.iter().enumerate() { @@ -113,7 +162,6 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { unsupported_typography .insert(format!("Kinsoku priority `{}` is preserved, but push-in/push-out priority is not applied", para.kinsoku_type)); } - let mut fams = vec![base.font_family.clone()]; for (run, f) in story.runs().filter(|(run, _)| run.start < range.end && run.end > range.start) { let props = d.styles.resolve_char(&base, f); if has_rtl @@ -130,26 +178,33 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { unsupported_typography.insert(format!("Unknown positional form `{}` is preserved but not applied", props.positional_form)); } } - fams.push(props.font_family); - } - for fam in fams { - let composite = d.styles.composite_fonts.iter().find(|f| f.name == fam.trim_start_matches("CompositeFont/")); - let families: Vec<&str> = composite.map_or_else(|| vec![fam.as_str()], |f| f.entries.iter().map(|e| e.family.as_str()).collect()); - for family in families { - if !db.has_family(family) && !missing_fonts.iter().any(|f| f == family) { - missing_fonts.push(family.to_string()); - } - } } } } + // Use the same effective character-to-font mapping as Find Font, including + // anonymous note/cell stories. Unused composite entries are not missing text. + for font in super::fonts::used_fonts(d) { + if font.style_missing { + out.push(Issue { + severity: "error", + kind: "missingFontStyle", + message: format!("Missing font style: {} {} (using {} {})", font.family, font.style, font.resolved_family, font.resolved_style), + item: None, + page: None, + }); + } + + if font.missing && !missing_fonts.contains(&font.family) { + missing_fonts.push(font.family); + } + } for f in missing_fonts { out.push(Issue { severity: "error", kind: "missingFont", message: format!("Missing font: {f}"), item: None, page: None }); } for message in unsupported_typography { out.push(Issue { severity: "warning", kind: "unsupportedTypography", message, item: None, page: None }); } - for (face, chars) in missing { + for (face, chars, item) in missing { let listed: Vec = chars.iter().take(MISSING_GLYPHS_LISTED).map(char::to_string).collect(); let more = chars.len().saturating_sub(MISSING_GLYPHS_LISTED); let more = if more > 0 { format!(" and {more} more") } else { String::new() }; @@ -157,8 +212,8 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { severity: "error", kind: "missingGlyphs", message: format!("Missing glyphs in {} {}: {}{more}", face.family, face.style, listed.join(" ")), - item: None, - page: None, + item: Some(item.0), + page: d.find(item).zip(d.item(item)).and_then(|(loc, it)| page_of(d, loc.spread, it)), }); } // Items. @@ -322,3 +377,26 @@ mod missing_glyph_tests { assert!(found[0].1.ends_with(" and 10 more"), "{}", found[0].1); } } + +#[cfg(test)] +mod generated_glyph_tests { + use super::*; + use std::sync::Arc; + + #[test] + fn generated_suffix_missing_glyph_is_reported_at_its_page_frame() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let created = s.execute("frame.create", &json!({"rect":[20,20,300,400],"content":"text","text":"Body"})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let sid = *d.stories.keys().next().unwrap(); + d.footnote_options.affix_in = designcraft_doc::notes::AffixIn::Reference; + d.footnote_options.suffix = "\u{10fffd}".into(); + d.story_mut(sid).unwrap().insert_note(4, "Note", designcraft_doc::ParaFormat::default()); + let report = s.execute("preflight.run", &json!({})).unwrap(); + let issue = report["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "missingGlyphs").unwrap(); + assert!(issue["message"].as_str().unwrap().contains('\u{10fffd}')); + assert_eq!(issue["item"], created["id"]); + assert_eq!(issue["page"], 0); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8f2f39b0f..4baaa3e4f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -537,3 +537,31 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn preflight_distinguishes_unplaced_stories_and_locates_footnote_overset() { + use designcraft_doc::{AnchorPosition, AnchoredObject, ParaFormat, SpreadRef, Story, StoryId}; + use designcraft_geom::Rect; + let mut s = session(); + let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let layer = doc.default_layer(); + let (host, body) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 20.0, 400.0, 600.0), layer, "Body", ParaFormat::default()).unwrap(); + let (frame, _) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 5.0, 2.0), layer, "Overflow", ParaFormat::default()).unwrap(); + let item = doc.item(frame).unwrap().clone(); + doc.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != frame); + let story = doc.story_mut(body).unwrap(); + story.insert_note(4, "Note", ParaFormat::default()); + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 })); + let unplaced = StoryId(doc.alloc()); + doc.stories.insert(unplaced, Arc::new(Story::with_text(unplaced, "Unplaced", ParaFormat::default()))); + doc.check().unwrap(); + let issues = s.execute("preflight.run", &json!({})).unwrap(); + let issues = issues["issues"].as_array().unwrap(); + let overset: Vec<_> = issues.iter().filter(|i| i["kind"] == "overset").collect(); + assert_eq!(overset.len(), 1, "{issues:?}"); + assert_eq!(overset[0]["item"], host.0); + assert_eq!(overset[0]["page"], 0); + let unplaced = issues.iter().find(|i| i["kind"] == "unplacedStory").unwrap(); + assert_eq!(unplaced["severity"], "warning"); + assert!(unplaced.get("page").is_none()); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 4177f941c..2bc2b00ed 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -121,6 +121,8 @@ pub struct FontFace { em: std::sync::OnceLock<(f64, f64)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option)>, + family_aliases: Vec, + style_aliases: Vec, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -207,15 +209,23 @@ impl FontFace { pub fn id(&self) -> u32 { self.id } - /// Does the face map `c` to a glyph? + /// System placeholder fonts draw generic boxes rather than the requested character. + pub fn is_placeholder(&self) -> bool { + matches!(norm(self.family.trim_start_matches('.')).as_str(), "lastresort") + } + + /// Does the face map `c` to a real glyph? pub fn covers(&self, c: char) -> bool { + if self.is_placeholder() { + return false; + } let cp = c as u32; if cp < 0x1_0000 { let bits = self.bmp.get_or_init(|| { let mut b = vec![0u64; 1024].into_boxed_slice(); if let Some(f) = self.skrifa() { - for (cp, _) in f.charmap().mappings() { - if cp < 0x1_0000 { + for (cp, gid) in f.charmap().mappings() { + if cp < 0x1_0000 && gid.to_u32() != 0 { b[(cp / 64) as usize] |= 1 << (cp % 64); } } @@ -224,7 +234,7 @@ impl FontFace { }); return bits[(cp / 64) as usize] & (1 << (cp % 64)) != 0; } - self.skrifa().is_some_and(|f| f.charmap().map(c).is_some()) + self.skrifa().is_some_and(|f| f.charmap().map(c).is_some_and(|gid| gid.to_u32() != 0)) } /// Units per em. pub fn units_per_em(&self) -> f64 { @@ -304,6 +314,7 @@ struct CatalogEntry { /// The font menu group and native family name, read by the scan. group: FontGroup, native: Option, + aliases: Vec, } /// What the scan reads of one face in a font file: its names and font menu group. @@ -314,6 +325,7 @@ struct ScannedFace { style: String, group: FontGroup, native: Option, + aliases: Vec, } /// Process-wide font database: the fonts every document shares (bundled, installed, added), and @@ -492,6 +504,14 @@ static NEXT_ID: AtomicU32 = AtomicU32::new(1); static NEXT_SCOPE: AtomicU32 = AtomicU32::new(1); const OUTLINE_CACHE_MAX: usize = 50_000; +fn aliases(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Vec { + let mut names: Vec = + ids.iter().flat_map(|id| font.localized_strings(*id)).take(256).map(|name| name.to_string()).filter(|name| !name.trim().is_empty()).collect(); + names.sort(); + names.dedup(); + names +} + fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) } @@ -567,7 +587,7 @@ fn file_face_names(path: &std::path::Path) -> Vec { crate::group::classify(&skrifa::FontRef::new(&font).ok()?, &family) } }; - Some(ScannedFace { family, style, group, native }) + Some(ScannedFace { family, style, group, native, aliases: aliases(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]) }) }) .collect() } @@ -688,6 +708,8 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s .map(|v| v >= 0.5) .or(axis(b"slnt").map(|v| v.abs() > 0.1)) .unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style)); + let family_aliases = aliases(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]); + let style_aliases = aliases(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]); Some(FontFace { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), family, @@ -708,6 +730,8 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s index, bmp: std::sync::OnceLock::new(), em: std::sync::OnceLock::new(), + family_aliases, + style_aliases, group: std::sync::OnceLock::new(), }) } @@ -916,7 +940,14 @@ impl FontDb { continue; } for f in file_face_names(&p) { - found.push(CatalogEntry { family: f.family, style: f.style, path: p.clone(), group: f.group, native: f.native }); + found.push(CatalogEntry { + family: f.family, + style: f.style, + path: p.clone(), + group: f.group, + native: f.native, + aliases: f.aliases, + }); } } } @@ -1070,6 +1101,7 @@ impl FontDb { self.scoped(0).has_family(family) } + #[cfg(not(target_arch = "wasm32"))] fn is_loaded(&self, family: &str) -> bool { self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) } @@ -1185,6 +1217,89 @@ impl ScopedFonts<'_> { self.own.as_deref().unwrap_or(&[]) } + /// Resolve localized and legacy family names without confusing replacement fonts with + /// installed originals. Document-local aliases take precedence over shared fonts. + pub fn canonical_family(&self, family: &str) -> Option { + let matches = + |f: &&Arc| f.family.eq_ignore_ascii_case(family) || f.family_aliases.iter().any(|alias| alias.eq_ignore_ascii_case(family)); + if let Some(face) = self.own().iter().find(matches) { + return Some(face.family.clone()); + } + if let Some(face) = self.db.read_faces().iter().find(matches) { + return Some(face.family.clone()); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(face) = self + .db + .read_catalog() + .iter() + .find(|f| f.family.eq_ignore_ascii_case(family) || f.aliases.iter().any(|alias| alias.eq_ignore_ascii_case(family))) + { + return Some(face.family.clone()); + } + None + } + + /// Whether the requested style exists, including localized names and axis instances. + pub fn has_style(&self, family: &str, style: &str) -> bool { + if !self.has_family(family) { + return false; + } + let face = self.face(family, style); + norm(&face.style) == norm(style) || (face.coords.is_empty() && face.style_aliases.iter().any(|alias| norm(alias) == norm(style))) + } + + /// Prefer known metric-compatible substitutes, then an appropriate script family. This + /// never changes `has_family`: a usable substitute is still a missing original font. + pub fn substitute_family(&self, family: &str) -> String { + let lower = family.to_lowercase(); + let candidates: &[&str] = match lower.as_str() { + "calibri" | "calibri light" => &["Carlito", "Liberation Sans"], + "cambria" | "cambria math" => &["Caladea", "Liberation Serif"], + "arial" | "helvetica" => &["Liberation Sans", "Arial", "Helvetica"], + "times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times"], + "courier new" | "courier" => &["Liberation Mono", "Courier New", "Courier"], + _ if lower.contains("arabic") || lower.contains("naskh") => &["Amiri", "Noto Naskh Arabic", "Geeza Pro"], + _ if family.contains('楷') => &["Kaiti SC", "STKaiti", "KaiTi", "Songti SC"], + _ if family.contains("仿宋") => &["STFangsong", "FangSong", "Songti SC"], + _ if family.contains('宋') || family.contains("明朝") => &["Songti SC", "Noto Serif CJK SC", "SimSun"], + _ if family.contains('黑') || family.contains("雅黑") => &["PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei"], + _ if family.chars().any(|c| ('\u{3400}'..='\u{9fff}').contains(&c)) => &["Songti SC", "Noto Serif CJK SC", "PingFang SC"], + _ => &[], + }; + candidates.iter().find_map(|name| self.canonical_family(name)).unwrap_or_else(|| FALLBACK_FAMILY.to_string()) + } + + /// A replacement suggestion must cover the text being replaced, not just its main script. + /// Ties retain the preferred substitute; this does not install or redefine any font. + pub fn replacement_face(&self, family: &str, style: &str, characters: &[char]) -> Arc { + let mut best = self.face(family, style); + let coverage = |face: &FontFace| characters.iter().filter(|&&c| c.is_whitespace() || c.is_control() || face.covers(c)).count(); + let mut score = coverage(&best); + let candidates: &[&str] = if characters.iter().any(|c| ('\u{0600}'..='\u{08ff}').contains(c)) { + &["Amiri", "Noto Naskh Arabic", "Al Bayan", "Damascus", "Geeza Pro", "Arial"] + } else if characters.iter().any(|c| ('\u{3400}'..='\u{9fff}').contains(c)) { + &["Songti SC", "Kaiti SC", "Noto Serif CJK SC", "PingFang SC", "Noto Sans CJK SC"] + } else { + &["Source Serif 4", "Source Sans 3", "Inter"] + }; + for candidate in candidates { + if score == characters.len() { + break; + } + if !self.has_family(candidate) { + continue; + } + let face = self.face(candidate, style); + let next = coverage(&face); + if next > score { + best = face; + score = next; + } + } + best + } + /// Family names available (the document's, loaded and cataloged system fonts), sorted and /// deduplicated. pub fn families(&self) -> Vec { @@ -1218,6 +1333,8 @@ impl ScopedFonts<'_> { /// Style names available for `family` (Regular first, then by weight). pub fn styles(&self, family: &str) -> Vec { + let canonical = self.canonical_family(family); + let family = canonical.as_deref().unwrap_or(family); let mut v: Vec<(bool, f32, String)> = self .own() .iter() @@ -1239,6 +1356,8 @@ impl ScopedFonts<'_> { /// 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 { + let canonical = self.canonical_family(family).unwrap_or_else(|| self.substitute_family(family)); + let family = canonical.as_str(); if let Some(f) = self.find(family, style) { return f; } @@ -1256,14 +1375,7 @@ impl ScopedFonts<'_> { /// 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 + self.canonical_family(family).is_some() } /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. @@ -1327,12 +1439,14 @@ impl ScopedFonts<'_> { 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(); + let own: Vec<&Arc> = self.own().iter().filter(named).collect(); + let cands: Vec<&Arc> = if own.is_empty() { faces.iter().filter(named).collect() } else { own }; if cands.is_empty() { return None; } let ns = norm(style); - if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { + if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns || (f.coords.is_empty() && f.style_aliases.iter().any(|alias| norm(alias) == ns))) + { return Some((*f).clone()); } let (tw, ti) = (style_weight(style), style_italic(style)); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 8f901e96d..60a474b3b 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -530,3 +530,62 @@ mod tests { } } } + +#[cfg(test)] +mod resolution_tests { + use super::*; + + #[test] + fn localized_family_and_style_names_resolve_without_false_missing_status() { + let bytes = testing::font_with("Alias Face", &['A']).unwrap(); + let bytes = testing::with_names( + &bytes, + &[(3, 1, 0x409, 1, "Alias Face"), (3, 1, 0x804, 1, "别名字体"), (3, 1, 0x409, 2, "Regular"), (3, 1, 0x804, 2, "常规")], + ) + .unwrap(); + let db = FontDb::with_font_dirs(Vec::new()); + db.add_font(bytes); + let scope = db.scoped(0); + assert!(scope.has_family("别名字体")); + assert_eq!(scope.face("别名字体", "常规").family, "Alias Face"); + assert!(scope.has_style("别名字体", "常规")); + assert!(!scope.has_style("别名字体", "Unreal Style")); + assert_eq!(scope.styles("别名字体"), ["Regular"]); + } + + #[test] + fn known_substitution_preserves_missing_original_status() { + let db = FontDb::with_font_dirs(Vec::new()); + db.add_font(testing::font_with("Carlito", &['A']).unwrap()); + let scope = db.scoped(0); + assert_eq!(scope.substitute_family("Calibri"), "Carlito"); + assert!(scope.canonical_family("An Unavailable Original Family").is_none()); + assert_eq!(scope.face("An Unavailable Original Family", "Regular").family, FALLBACK_FAMILY); + } +} + +#[cfg(test)] +mod coverage_recommendation_tests { + use super::*; + + #[test] + fn placeholder_fonts_do_not_satisfy_glyph_coverage() { + let db = FontDb::with_font_dirs(Vec::new()); + db.add_font(testing::font_with(".LastResort", &['\u{10fffd}']).unwrap()); + let face = db.face(".LastResort", "Regular"); + assert!(face.is_placeholder()); + assert!(!face.covers('\u{10fffd}')); + assert!(db.fallback_for('\u{10fffd}', u32::MAX, None).is_none()); + } + + #[test] + fn arabic_replacement_also_covers_surrounding_punctuation() { + let db = FontDb::with_font_dirs(Vec::new()); + db.add_font(testing::font_with("Geeza Pro", &['ع']).unwrap()); + db.add_font(testing::font_with("Al Bayan", &['ع', '“']).unwrap()); + let scope = db.scoped(0); + assert_eq!(scope.face("Traditional Arabic", "Regular").family, "Geeza Pro"); + assert_eq!(scope.replacement_face("Traditional Arabic", "Regular", &['ع', '“']).family, "Al Bayan"); + assert!(!scope.has_family("Traditional Arabic")); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 919991eb0..8dd965473 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1021,9 +1021,13 @@ impl<'a> Ex<'a> { el.set("CharacterAlignment", crate::cjk::alignment_out(v)); } if let Some(f) = &a.font_family { - props.push(p("AppliedFont", "string", f.clone())); - let style = a.font_style.clone().unwrap_or_else(|| "Regular".into()); - self.note_font(f, &style); + if let Some(composite) = self.d.styles.composite_font(f) { + props.push(p("AppliedFont", "object", format!("CompositeFont/{}", escape_id(&composite.name)))); + } else { + props.push(p("AppliedFont", "string", f.clone())); + let style = a.font_style.clone().unwrap_or_else(|| "Regular".into()); + self.note_font(f, &style); + } } if let Some(v) = &a.font_style { el.set("FontStyle", v); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 3e1c17e28..a979c7c4f 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -104,6 +104,7 @@ struct Importer<'r> { inks: designcraft_doc::InkManager, stroke_styles: Vec, styles: Styles, + composite_names: HashMap, kinsoku: HashMap, para_names: HashMap, char_names: HashMap, @@ -188,6 +189,7 @@ impl<'r> Importer<'r> { inks: Default::default(), stroke_styles: Vec::new(), styles, + composite_names: HashMap::new(), kinsoku: HashMap::new(), para_names: HashMap::new(), char_names: HashMap::new(), @@ -273,6 +275,11 @@ impl<'r> Importer<'r> { } } for e in top.iter().filter(|e| e.local() == "CompositeFont") { + let name = + e.get("Name").map(str::to_string).unwrap_or_else(|| unescape_id(e.get("Self").unwrap_or("").trim_start_matches("CompositeFont/"))); + if let Some(id) = e.get("Self") { + self.composite_names.insert(id.to_string(), format!("CompositeFont/{name}")); + } let entries = e .find_all("CompositeFontEntry") .map(|c| designcraft_doc::cjk::CompositeFontEntry { @@ -287,7 +294,7 @@ impl<'r> Importer<'r> { scale_option: c.boolean("ScaleOption").unwrap_or(true), }) .collect(); - self.styles.composite_fonts.push(designcraft_doc::cjk::CompositeFont { name: unescape_id(e.get("Name").unwrap_or("")), entries }); + self.styles.composite_fonts.push(designcraft_doc::cjk::CompositeFont { name, entries }); } for e in top.iter().filter(|e| e.local() == "KinsokuTable") { if e.get("CantBeginLineChars").is_some() || e.get("CantEndLineChars").is_some() { @@ -376,11 +383,15 @@ impl<'r> Importer<'r> { self.index_topics_of(ix, &[]); } // Stories (ids first so frames can reference them). + let mut pending_stories = Vec::new(); for e in top.iter().filter(|e| e.local() == "Story") { let id = StoryId(self.alloc()); if let Some(s) = e.get("Self") { self.story_ids.insert(s.to_string(), id); } + pending_stories.push((id, e)); + } + for (id, e) in pending_stories { let story = self.story(id, e); self.stories.insert(id, story); } @@ -1050,7 +1061,7 @@ impl<'r> Importer<'r> { // Some writers append the style after a tab. let fam = f.split('\t').next().unwrap_or(f); if !fam.is_empty() && fam != "$ID/" { - a.font_family = Some(fam.to_string()); + a.font_family = Some(self.composite_names.get(fam).cloned().unwrap_or_else(|| fam.to_string())); } } a.font_style = e.prop("FontStyle"); @@ -1668,11 +1679,9 @@ impl<'r> Importer<'r> { rcf.over.position = None; b.push(&designcraft_doc::FOOTNOTE_REF.to_string(), &rcf); } - // Not supported yet: skip their content entirely. - "Rectangle" | "Oval" | "Polygon" | "GraphicLine" | "Group" => { - // An anchored object (text frames inside stories aren't supported yet). - let has_text = c.find_all("TextFrame").next().is_some(); - if !has_text && let Some(item) = self.item(c, designcraft_geom::Affine::IDENTITY) { + // Anchored page items, including groups containing text frames. + "Rectangle" | "Oval" | "Polygon" | "GraphicLine" | "Group" | "TextFrame" => { + if let Some(item) = self.item(c, designcraft_geom::Affine::IDENTITY) { use designcraft_doc::anchored::AnchorAlign as A; let set = c.find("AnchoredObjectSetting"); let get = |k: &str| set.and_then(|s| s.num(k)).unwrap_or(0.0); @@ -1714,7 +1723,7 @@ impl<'r> Importer<'r> { b.push(&designcraft_doc::OBJECT_MARK.to_string(), cf); } } - "Properties" | "Note" | "TextFrame" | "StoryPreference" | "InCopyExportOption" | "TextVariableInstance" => {} + "Properties" | "Note" | "StoryPreference" | "InCopyExportOption" | "TextVariableInstance" => {} _ => self.walk_story(c, b, pf, pchars, cf, brk), }, Node::Pi(t, v) if t == "ACE" => { @@ -2147,8 +2156,8 @@ impl<'r> Importer<'r> { let threads = std::mem::take(&mut self.ctx.threads); let by_self: HashMap = self.item_ids.clone(); let mut per_story: BTreeMap> = BTreeMap::new(); - for (i, (id, _, _, _)) in threads.iter().enumerate() { - let sid = self.frame_story(*id); + for (i, (id, story, _, _)) in threads.iter().enumerate() { + let sid = self.story_ids.get(story).copied().or_else(|| self.frame_story(*id)); if let Some(sid) = sid { per_story.entry(sid).or_default().push(i); } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index b636a2ac1..5fd42f54e 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -857,3 +857,83 @@ fn arabic_story_and_table_directions_survive_idml_export() { assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft); assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft); } + +/// Synthetic forward references: both a body anchor and a footnote anchor own +/// a group containing a text frame whose story is serialized later. +#[test] +fn round_trips_anchored_text_frames_in_body_and_footnotes() { + use designcraft_doc::{AnchorPosition, AnchoredObject, Content, Item, ItemId}; + use std::sync::Arc; + let mut d = Document::new(&NewDocument::default()); + let layer = d.default_layer(); + let (_, body) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 20.0, 400.0, 600.0), layer, "Body", ParaFormat::default()).unwrap(); + d.story_mut(body).unwrap().insert_note(4, "Note", ParaFormat::default()); + for in_note in [false, true] { + let (frame, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 60.0, 30.0), layer, "Symbol", ParaFormat::default()).unwrap(); + let child = d.item(frame).unwrap().clone(); + d.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != frame); + let mut group = Item::new(ItemId(d.alloc()), layer, Shape::Group, Default::default()); + group.content = Content::Group { items: vec![Arc::new(child)] }; + let anchor = AnchoredObject::new(group, AnchorPosition::Inline { y_offset: 2.0 }); + let story = d.story_mut(body).unwrap(); + if in_note { + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, anchor); + } else { + story.insert_object(0, anchor); + } + } + d.check().unwrap(); + for _ in 0..2 { + d = import_idml(&export_idml(&d)).unwrap(); + d.check().unwrap(); + assert_eq!(d.stories.len(), 3); + let body = d.stories.values().find(|s| !s.notes.is_empty()).unwrap(); + assert_eq!(body.notes.len(), 1); + assert_eq!(body.objects.len(), 1); + assert_eq!(body.notes[0].text.objects.len(), 1); + let children: Vec<_> = d.anchored_items().into_iter().filter(|(_, i)| i.text_frame().is_some()).collect(); + assert_eq!(children.len(), 2); + for (_, item) in children { + let story = d.story(item.text_frame().unwrap().story).unwrap(); + assert_eq!(story.text, "Symbol"); + assert_eq!(story.frames, vec![item.id]); + } + } +} + +#[test] +fn composite_references_resolve_by_identity_and_export_as_objects() { + let map = DESIGNMAP.replace("", r#" + + Source Serif 4 + Source Sans 3 + "#); + let styles = STYLES + .replace("Source Serif 4", "CompositeFont/opaque%3a17"); + let story = r#"A1B2"#; + let bytes = zip_files(&[ + ("designmap.xml", &map), + ("Resources/Styles.xml", &styles), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", story), + ]); + let d = import_idml(&bytes).unwrap(); + let f = &d.styles.composite_fonts[0]; + assert_eq!(f.name, "Mixed%3a literal", "display names are not encoded identifiers"); + let body = d.stories.values().find(|s| !s.notes.is_empty()).unwrap(); + for text in [body.as_ref(), &body.notes[0].text] { + let (_, base) = d.styles.resolve_para(&text.paras[0]); + let p = d.styles.resolve_char(&base, &text.chars[0].format); + assert_eq!(d.styles.composite_font(&p.font_family), Some(f)); + } + let output = export_idml(&d); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&output)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("Resources/Styles.xml").unwrap(), &mut xml).unwrap(); + assert!(xml.contains("CompositeFont/Mixed%253a literal"), "{xml}"); + let back = import_idml(&output).unwrap(); + assert_eq!(back.styles.composite_fonts, d.styles.composite_fonts); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0c77e69b9..f6ede8851 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -141,6 +141,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) pdf.set_metadata(meta); let mut ex = Exporter { + drawing_items: Vec::new(), doc, cache, opts, @@ -313,6 +314,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R } pdf.set_metadata(meta); let mut ex = Exporter { + drawing_items: Vec::new(), doc, cache, opts: &pdf_opts, @@ -408,6 +410,7 @@ pub(crate) struct Exporter<'a> { /// Current sheet's visible area (spread space) for culling. pub clip: Rect, pub warnings: Vec, + drawing_items: Vec, images: HashMap>, /// Placed PDFs (embedded as vector pages). pdfs: HashMap>, @@ -711,6 +714,16 @@ impl Exporter<'_> { } fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { + if self.drawing_items.len() >= 64 || self.drawing_items.contains(&it.id) { + self.warnings.push(format!("Cyclic or excessively nested item {} was not rendered", it.id)); + return; + } + self.drawing_items.push(it.id); + self.item_guarded(s, it, parent, page_name); + self.drawing_items.pop(); + } + + fn item_guarded(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { let gf = &it.effects.gradient_feather; if !gf.on || it.hidden || it.nonprinting { return self.item_inner(s, it, parent, page_name); @@ -854,7 +867,6 @@ impl Exporter<'_> { s.push_transform(&tf(xf)); // Fill. if !it.fill.is_none() - && it.path.is_closed() && let Some(p) = &path && let Some(paint) = self.fill_paint(&it.fill, bp.bounding_box()) { @@ -921,16 +933,30 @@ impl Exporter<'_> { for _ in 0..pushes { s.pop(); } - // Anchored objects in the frame's text. - if let Content::Text(tfr) = &it.content - && let Some(st) = doc.story(tfr.story).filter(|st| !st.objects.is_empty()) - { + if let Content::Text(tfr) = &it.content { let cs = self.cache.get(doc, tfr.story, page_name); if let Some(ft) = cs.frame(it.id) { - for o in &ft.objects { - if let Some(obj) = st.objects.get(o.index) { - self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page_name); - } + self.frame_objects(s, ft, xf * doc.text_local(it), page_name); + } + } + } + + /// Draw embedded page items after restoring the surface, retaining spread-space + /// transforms for page culling (also for anonymous note and cell stories). + fn frame_objects(&mut self, s: &mut Surface, ft: &designcraft_compose::FrameText, xf: Affine, page_name: Option<&str>) { + let mut pending = vec![(ft, xf)]; + while let Some((frame, transform)) = pending.pop() { + for object in &frame.objects { + self.item(s, &object.object.item, transform * Affine::translate(object.origin.to_vec2()), page_name); + } + for note in &frame.notes { + if let Some(frame) = note.text.frames.first() { + pending.push((frame, transform * Affine::translate(note.origin.to_vec2()))); + } + } + for cell in frame.tables.iter().flat_map(|t| &t.cells) { + if let Some(frame) = cell.text.frames.first() { + pending.push((frame, transform * Affine::translate(cell.origin.to_vec2()))); } } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1771c4e4f..3908c0bc3 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -337,3 +337,32 @@ fn missing_glyphs_print_as_boxes() { assert_eq!(glyphs.iter().map(|g| g.0.as_str()).collect::>(), ["a", "b"], "{glyphs:?}"); assert_eq!(boxes(&paths), 1, "{paths:?}"); } + +#[test] +fn exports_embedded_text_from_footnotes() { + use designcraft_doc::{AnchorPosition, AnchoredObject}; + use std::sync::Arc; + let mut d = doc_with_text("Body"); + let body = *d.stories.keys().next().unwrap(); + let (fid, _) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 100.0, 40.0), d.default_layer(), "Embedded", ParaFormat::default()).unwrap(); + let item = d.item(fid).unwrap().clone(); + d.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != fid); + let story = d.story_mut(body).unwrap(); + story.insert_note(4, "Note", ParaFormat::default()); + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 })); + d.check().unwrap(); + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let text = extract_text(&bytes).join(""); + assert!(text.contains("Embedded"), "{text}"); +} + +#[test] +fn fills_open_contours_without_changing_strokes() { + let mut d = Document::new(&NewDocument::default()); + add_box(&mut d, Rect::new(40.0, 50.0, 62.0, 67.0), designcraft_color::swatch::BLACK); + let id = d.spreads[0].items[0].id; + d.item_mut(id).unwrap().path.subpaths[0].closed = false; + let (_, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap()); + assert!(paths.iter().any(|r| (r.width() - 22.0).abs() < 0.01 && (r.height() - 17.0).abs() < 0.01), "{paths:?}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c1b288def..86dba8dac 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -217,6 +217,7 @@ pub struct Renderer { glyphs: text::GlyphCache, /// Composed stories looked up during the current render. stories: HashMap<(StoryId, Option), Arc>, + drawing_items: Vec, pub threads: u16, pub stats: FrameStats, } @@ -262,6 +263,7 @@ impl Renderer { resources: Resources::new(), glyphs: text::GlyphCache::default(), stories: HashMap::new(), + drawing_items: Vec::new(), threads: default_threads(), stats: FrameStats::default(), } @@ -432,6 +434,15 @@ impl Renderer { } fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) { + if self.drawing_items.len() >= 64 || self.drawing_items.contains(&it.id) { + return; + } + self.drawing_items.push(it.id); + self.draw_item_inner(ctx, f, it, parent, page_name); + self.drawing_items.pop(); + } + + fn draw_item_inner(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) { if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting { return; } @@ -551,7 +562,7 @@ impl Renderer { if op { ctx.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None); } - if set_fill_paint(ctx, doc, &it.fill, bp.bounding_box()) && it.path.is_closed() { + if set_fill_paint(ctx, doc, &it.fill, bp.bounding_box()) { ctx.fill_path(bp); } ctx.reset_paint_transform(); @@ -1169,6 +1180,31 @@ mod tests { use designcraft_doc::build::NewDocument; use designcraft_doc::{Fill, ParaFormat}; + #[test] + fn footnote_vector_objects_are_painted() { + use designcraft_doc::{AnchorPosition, AnchoredObject, ItemId, Shape, SpreadRef}; + let mut d = Document::new(&NewDocument::default()); + let (_, sid) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 20.0, 300.0, 300.0), d.default_layer(), "Body", ParaFormat::default()).unwrap(); + let mut item = + Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 12.0))); + item.fill = Fill::swatch(designcraft_color::swatch::BLACK); + // Open contours with a fill are implicitly closed for painting. A single + // open contour must not hide the whole compound glyph. + item.path.subpaths[0].closed = false; + let story = d.story_mut(sid).unwrap(); + story.insert_note(4, "", ParaFormat::default()); + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 })); + let cache = Cache::new(); + let cs = cache.get(&d, sid, None); + let note = &cs.frames[0].notes[0]; + let object = ¬e.text.frames[0].objects[0]; + let centre = note.origin + object.origin.to_vec2() + designcraft_geom::Vec2::new(6.0, 6.0); + let image = Renderer::new().render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + let pixel = image.pixel(centre.x as u32, centre.y as u32); + assert!(pixel[0] < 80 && pixel[1] < 80 && pixel[2] < 80, "{pixel:?}"); + } + /// `Rendered` has public fields: a buffer that doesn't match the stated size used to panic /// in `to_png`. /// A canvas 65535 pixels wide (any render wider than that was clamped to it) panicked inside diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 165366eb7..98710e659 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -392,14 +392,8 @@ impl Renderer { } } // Anchored objects ride on their lines. - if !ft.objects.is_empty() - && let Some(st) = doc.story(cs.story) - { - for o in &ft.objects { - if let Some(obj) = st.objects.get(o.index) { - self.draw_item(ctx, f, &obj.item, xf * Affine::translate(o.origin.to_vec2()), None); - } - } + for o in &ft.objects { + self.draw_item(ctx, f, &o.object.item, xf * Affine::translate(o.origin.to_vec2()), None); } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 34832db14..2ef15a776 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1373,7 +1373,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( - egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, " OK ")).color(egui::Color32::WHITE))) + egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, if d.id == "findFont" { "Replace All" } else { " OK " })).color(egui::Color32::WHITE))) .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() @@ -1381,7 +1381,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { { result = Some(true); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if d.id == "findFont" { "Done" } else { "Cancel" }))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { result = Some(false); } }); @@ -1627,11 +1627,19 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "polygonSettings" => app.run("tool.polygonSettings", json!({"sides": d.n("sides").unwrap_or(6.0) as u64, "starInset": d.n("starInset").unwrap_or(0.0)})), "findFont" => { - let (f, st) = (d.s("family"), d.s("style")); - if f.is_empty() || d.s("toFamily").is_empty() { - return Ok(Value::Null); + let result = app.run("font.replace", json!({"family": d.s("family"), "style": d.s("style"), "toFamily": d.s("toFamily"), "toStyle": d.s("toStyle")})); + let mut d = d; + match &result { + Ok(_) => { + d.fields.remove("_fonts"); + d.fields.remove("family"); + d.fields.remove("style"); + d.fields.remove("error"); + } + Err(error) => { d.fields.insert("error".into(), json!(error)); } } - app.run("font.replace", json!({"family": f, "style": st, "toFamily": d.s("toFamily"), "toStyle": d.s("toStyle")})) + app.ui.dialog = Some(d); + result } "insertXref" => { let format = d.s("format"); @@ -2603,6 +2611,24 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert("_fonts".into(), list); } let fonts = d.fields.get("_fonts").and_then(Value::as_array).cloned().unwrap_or_default(); + if !fonts.iter().any(|font| font["family"] == d.s("family") && font["style"] == d.s("style")) + && let Some(font) = fonts.first() + { + d.fields.insert("family".into(), font["family"].clone()); + d.fields.insert("style".into(), font["style"].clone()); + } + if d.b("onOpen") && fonts.iter().any(|f| f["missing"] == true || f["styleMissing"] == true) { + crate::rtl::label( + ui, + crate::i18n::tr( + &app.ui.language, + "This document uses unavailable fonts. Choose replacements, or select Done to continue with fallback fonts.", + ), + ); + } + if !d.s("error").is_empty() { + crate::rtl::label(ui, d.s("error")); + } let missing = fonts.iter().filter(|f| f["missing"] == true || f["styleMissing"] == true).count(); crate::rtl::label( ui, @@ -2617,8 +2643,8 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let label = format!("{}{fam} {st}", if warn { "\u{26A0} " } else { "" }); let on = d.s("family") == fam && d.s("style") == st; let mut text = egui::RichText::new(label); - if warn { - text = text.color(egui::Color32::from_rgb(0xe5, 0x4b, 0x4b)); + if warn && !on { + text = text.color(crate::theme::Tokens::get(ui.ctx()).text_strong); } if ui.selectable_label(on, text).clicked() { d.fields.insert("family".into(), json!(fam)); @@ -2626,10 +2652,30 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } }); + let key = format!("{}\n{}", d.s("family"), d.s("style")); + if let Some(font) = fonts.iter().find(|f| f["family"] == d.s("family") && f["style"] == d.s("style")) { + if d.s("_fontKey") != key { + d.fields.insert("_fontKey".into(), json!(key)); + d.fields.insert("toFamily".into(), font["replacementFamily"].clone()); + d.fields.insert("toStyle".into(), font["replacementStyle"].clone()); + } + crate::rtl::label( + ui, + format!( + "{}: {} {}", + crate::i18n::tr(&app.ui.language, "Currently Rendered With"), + font["resolvedFamily"].as_str().unwrap_or(""), + font["resolvedStyle"].as_str().unwrap_or("") + ), + ); + } 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 = crate::panels::fonts(app); let menu = crate::panels::font_menu(app); + if d.s("toFamily").is_empty() { + d.fields.insert("toFamily".into(), json!(designcraft_fonts::FALLBACK_FAMILY)); + } let english = app.session.prefs.show_font_names_in_english; let fam_opts: Vec<(&str, &str)> = menu.iter().map(|f| (f.family.as_str(), f.label(english))).collect(); egui::Grid::new("ff_to").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { @@ -2637,6 +2683,17 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { combo(ui, d, "toFamily", &fam_opts); ui.end_row(); let styles = db.styles(&d.s("toFamily")); + if !styles.iter().any(|style| style == &d.s("toStyle")) { + let preferred = d.s("style"); + let style = styles + .iter() + .find(|s| s.eq_ignore_ascii_case(&preferred)) + .or_else(|| styles.iter().find(|s| s.eq_ignore_ascii_case("Regular"))) + .or_else(|| styles.first()); + if let Some(style) = style { + d.fields.insert("toStyle".into(), json!(style)); + } + } let st_opts: Vec<(&str, &str)> = styles.iter().map(|s| (s.as_str(), s.as_str())).collect(); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Style:")); combo(ui, d, "toStyle", &st_opts); @@ -2644,9 +2701,12 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }); ui.label(crate::rtl::widget( ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "OK changes all: text and paragraph/character styles.")) - .color(crate::theme::Tokens::get(ui.ctx()).text_dim) - .size(11.0), + egui::RichText::new(crate::i18n::tr( + &app.ui.language, + "Replace All updates this font throughout text and styles. Done keeps remaining fallback fonts.", + )) + .color(crate::theme::Tokens::get(ui.ctx()).text_dim) + .size(11.0), )); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7b474fa21..0d7bb8a0b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -499,6 +499,24 @@ impl DesignApp { } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); self.after_engine(); + if r.is_ok() && matches!(id, "file.open" | "file.openIdml" | "file.openBytes") { + if self.ui.dialog.as_ref().is_some_and(|d| d.id == "findFont") { + self.ui.dialog = None; + } + match self.session.execute("font.list", &json!({})) { + Ok(fonts) if fonts.as_array().is_some_and(|fonts| fonts.iter().any(|f| f["missing"] == true || f["styleMissing"] == true)) => { + self.ui.dialog = Some(dialogs::Dialog::new("findFont", json!({"_fonts": fonts, "onOpen": true}))); + } + Err(e) => self.status(e.to_string()), + _ => {} + } + } + if r.is_ok() + && matches!(id, "font.replace" | "edit.undo" | "edit.redo") + && let Some(dialog) = self.ui.dialog.as_mut().filter(|d| d.id == "findFont") + { + dialog.fields.remove("_fonts"); + } if let Err(e) = &r { self.status(e.clone()); } @@ -872,6 +890,40 @@ pub fn now_ms() -> f64 { mod tests { use super::*; + #[test] + fn opening_missing_fonts_offers_repeated_replacement_and_keeps_errors_visible() { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect":[0,0,200,200], "content":"text", "text":"Sample"})).unwrap(); + let d = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + let sid = *d.stories.keys().next().unwrap(); + d.story_mut(sid).unwrap().format_chars(0..6, |f| { + f.over.font_family = Some("Unavailable Test Family".into()); + f.over.font_style = Some("Regular".into()); + }); + let path = std::env::temp_dir().join(format!("designcraft-font-open-{}.designcraft", std::process::id())); + app.run("file.save", json!({"path":path})).unwrap(); + app.run("file.open", json!({"path":path})).unwrap(); + let dialog = app.ui.dialog.as_mut().unwrap(); + assert_eq!(dialog.id, "findFont"); + assert_eq!(dialog.fields["onOpen"], true); + dialog.fields.insert("family".into(), json!("Unavailable Test Family")); + dialog.fields.insert("style".into(), json!("Regular")); + dialog.fields.insert("toFamily".into(), json!("Unavailable Target")); + dialog.fields.insert("toStyle".into(), json!("Regular")); + assert!(dialogs::confirm(&mut app).is_err()); + assert!(app.ui.dialog.as_ref().unwrap().fields.contains_key("error")); + app.ui.dialog.as_mut().unwrap().fields.insert("toFamily".into(), json!("Source Sans 3")); + dialogs::confirm(&mut app).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("_fonts")); + assert!(app.run("font.list", json!({})).unwrap().as_array().unwrap().iter().all(|f| f["missing"] == false)); + app.ui.dialog = None; + app.run("file.save", json!({"path":path})).unwrap(); + app.run("file.open", json!({"path":path})).unwrap(); + assert!(app.ui.dialog.is_none()); + std::fs::remove_file(path).unwrap(); + } + #[test] fn snap_view_uses_the_saved_switches() { let mut ui = UiState::default(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 6d86bb565..948e45c51 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -255,7 +255,34 @@ impl Tokens { /// Install the UI fonts for the interface language `lang` (it orders the CJK fallbacks). pub fn install_fonts(ctx: &egui::Context, lang: &str) { - ctx.set_fonts(font_definitions(designcraft_fonts::CRAFT_FONTS, lang)); + let fonts = font_definitions(designcraft_fonts::CRAFT_FONTS, lang); + #[cfg(not(target_arch = "wasm32"))] + let fonts = { + let mut fonts = fonts; + // Local builds may omit craft-fonts. Use the shared resolver for readable document + // names and font menus without bundling or copying installed fonts into the project. + if designcraft_fonts::CRAFT_FONTS.is_empty() { + let db = designcraft_fonts::FontDb::global(); + for c in ['中', 'あ', '한'] { + if let Some(face) = db.fallback_for(c, u32::MAX, Some(lang)) { + let name = format!("system-ui-{}", face.id()); + if fonts.font_data.contains_key(&name) { + continue; + } + let mut data = FontData::from_owned(face.data().to_vec()); + data.index = face.index(); + fonts.font_data.insert(name.clone(), Arc::new(data)); + for (family, stack) in &mut fonts.families { + if !matches!(family, FontFamily::Name(name) if name.starts_with("arabic")) { + stack.push(name.clone()); + } + } + } + } + } + fonts + }; + ctx.set_fonts(fonts); } /// The UI fonts: the app's own, then the craft-fonts faces from `craft` (empty without From e1755c3cbeb16ff28c8750035c78b937e2ea1b13 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 19:50:50 +0800 Subject: [PATCH 02/12] CJK-MOJI1: resolve Mojikumi tables and preserve IDML references --- ROADMAP.md | 2 + crates/doc/src/cjk.rs | 3 +- crates/doc/src/lib.rs | 1 + crates/doc/src/mojikumi.rs | 189 +++++++++++++++++++++++++++++++++++++ crates/idml/src/import.rs | 7 +- crates/idml/src/tests.rs | 7 +- 6 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 crates/doc/src/mojikumi.rs diff --git a/ROADMAP.md b/ROADMAP.md index e00fbc5da..2531871ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- CJK-MOJI1: resolve Chinese and half-em Mojikumi presets and directional custom overrides; preserve IDML table references independently of display names. + - FONT7: replacement recommendations check the characters actually used; placeholder LastResort fonts and glyph-zero mappings cannot masquerade as glyph coverage or suppress missing-glyph diagnostics. - FONT6: localized and legacy family/style names share canonical resolution across shaping, inventory and replacement. Known compatible and script-based substitutes retain missing status; Find Font shows the resolved face and offers it as the replacement, while preflight reports unavailable styles. diff --git a/crates/doc/src/cjk.rs b/crates/doc/src/cjk.rs index 1ad2221e4..a700ad0c5 100644 --- a/crates/doc/src/cjk.rs +++ b/crates/doc/src/cjk.rs @@ -127,8 +127,7 @@ pub enum KinsokuHang { Force, } -/// Preserved IDML spacing tables. Keeping these separate from the supported -/// character spacing is essential: a stored table is not an applied rule. +/// IDML spacing tables resolved by `mojikumi::Rules` for composition and diagnostics. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct MojikumiTable { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 4ac3b9a5d..32e0e90cf 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -21,6 +21,7 @@ pub mod endnotes; pub mod ids; pub mod index; pub mod item; +pub mod mojikumi; pub mod notes; pub mod otf; pub mod page; diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs new file mode 100644 index 000000000..72056e7ca --- /dev/null +++ b/crates/doc/src/mojikumi.rs @@ -0,0 +1,189 @@ +//! Unicode Mojikumi rules shared by composition and preflight. +//! +//! IDML class numbers follow the documented Mojikumi override interchange format; +//! they are not the similarly numbered JLReq classes. Spacing is measured in em. +use crate::{Styles, cjk::MojikumiAki}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct Aki { + pub min: f64, + pub desired: f64, + pub max: f64, + pub priority: u8, + /// Discrete endpoints rather than a continuously floating amount. + pub discrete: bool, +} +impl Aki { + fn fixed(value: f64) -> Self { + Self { min: value, desired: value, max: value, ..Self::default() } + } +} + +/// Resolved table; custom rows override the base preset in document order. +pub struct Rules<'a> { + chinese: bool, + rows: &'a [MojikumiAki], +} +impl<'a> Rules<'a> { + pub fn resolve(styles: &'a Styles, name: &str) -> Result, String> { + if matches!(name, "" | "Nothing" | "None") { + return Ok(None); + } + let key = name.strip_prefix("MojikumiTable/").unwrap_or(name); + let table = styles.mojikumi_tables.iter().find(|t| t.name == key); + let base = table.map_or(key, |t| if t.based_on.is_empty() { t.name.as_str() } else { t.based_on.as_str() }); + let chinese = match base.trim_start_matches("$ID/") { + "SimpChineseDefault" | "TradChineseDefault" => true, + "LineEndAllOneHalfEmEnum" | "kMojikumiDefaultName1" => false, + _ => return Err(format!("unknown base preset `{base}`")), + }; + let rows = table.map_or(&[][..], |t| t.overrides.as_slice()); + for r in rows { + if !valid_class(r.target_class) || !valid_class(r.side_class) { + return Err(format!("unsupported character classes {}/{}", r.target_class, r.side_class)); + } + if ![r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite() && (-1.0..=100.0).contains(v)) + || r.minimum > r.desired + || r.desired > r.maximum + || !(0..=9).contains(&r.priority) + { + return Err("invalid spacing range or priority".into()); + } + } + Ok(Some(Self { chinese, rows })) + } + + pub fn pair(&self, left: i16, right: i16) -> Aki { + if let Some(row) = self + .rows + .iter() + .rev() + .find(|r| if r.after { r.target_class == left && r.side_class == right } else { r.target_class == right && r.side_class == left }) + { + return Aki { + min: row.minimum, + desired: row.desired, + max: row.maximum, + priority: if row.priority == 0 { 10 } else { row.priority as u8 }, + discrete: row.does_not_float, + }; + } + // Boundaries: Chinese keeps full punctuation; the half-em Japanese set + // trims opening punctuation at line start and closing punctuation at end. + if matches!(left, 22 | 23) { + return Aki::fixed(if self.chinese { leading(right) } else { 0.0 }); + } + if right == 22 { + return Aki::fixed(if self.chinese { trailing(left) } else { 0.0 }); + } + let punctuation = trailing(left).max(leading(right)); + if punctuation > 0.0 { + return Aki { min: 0.0, desired: punctuation, max: punctuation, priority: 1, discrete: false }; + } + let roman = |c| matches!(c, 18 | 25); + let ideograph = |c| matches!(c, 3 | 7..=9 | 11 | 12 | 24 | 33); + if (roman(left) && ideograph(right)) || (ideograph(left) && roman(right)) { + return Aki { min: 0.0, desired: 0.25, max: 0.5, priority: 3, discrete: false }; + } + if ideograph(left) && ideograph(right) { + return Aki { min: 0.0, desired: 0.0, max: 0.25, priority: 10, discrete: false }; + } + Aki::default() + } +} + +pub fn valid_class(c: i16) -> bool { + matches!(c, 1..=12 | 18 | 21..=33) +} +/// Natural half-body blank before a full-width punctuation character. +pub fn leading(c: i16) -> f64 { + match c { + 1 | 26 | 27 => 0.5, + 4 | 5 | 32 => 0.25, + _ => 0.0, + } +} +pub fn trailing(c: i16) -> f64 { + match c { + 2 | 6 | 21 | 28..=31 => 0.5, + 4 | 5 | 32 => 0.25, + _ => 0.0, + } +} + +/// Unicode classification, independent of font availability and localized names. +pub fn class(c: char) -> i16 { + match c { + '「' | '『' | '「' => 26, + '(' => 27, + '〈' | '《' | '【' | '〔' | '〖' | '〘' | '[' | '{' | '‘' | '“' => 1, + '」' | '』' | '」' => 28, + ')' => 29, + '〉' | '》' | '】' | '〕' | '〗' | '〙' | ']' | '}' | '’' | '”' => 2, + '。' | '。' => 6, + '、' | '、' => 21, + ',' => 30, + '.' => 31, + ':' | ';' => 32, + '・' | '·' => 5, + '!' | '?' => 4, + '…' | '‥' | '—' | '―' => 7, + '¥' | '$' | '£' | '¥' => 8, + '%' | '‰' | '℃' | '°' => 9, + '\u{3000}' => 10, + 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' + | 'ー' | '々' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' => 3, + '\u{3040}'..='\u{309f}' => 11, + '\u{30a0}'..='\u{30ff}' | '\u{31f0}'..='\u{31ff}' => 33, + '0'..='9' => 24, + '0'..='9' => 25, + '\u{2e80}'..='\u{a4cf}' | '\u{ac00}'..='\u{d7af}' | '\u{f900}'..='\u{faff}' | '\u{ff01}'..='\u{ff60}' | '\u{20000}'..='\u{323af}' => 12, + _ => 18, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cjk::MojikumiTable; + + #[test] + fn overrides_are_directional_and_unknown_tables_are_not_silently_applied() { + let mut styles = Styles::default(); + styles.mojikumi_tables.push(MojikumiTable { + name: "Pair".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 12, + side_class: 18, + after: false, + minimum: 0.1, + desired: 0.2, + maximum: 0.3, + priority: 2, + does_not_float: false, + }], + }); + let rule = Rules::resolve(&styles, "MojikumiTable/Pair").unwrap().unwrap(); + assert_eq!(rule.pair(18, 12).desired, 0.2); + assert_eq!(rule.pair(12, 18).desired, 0.25); + assert!(Rules::resolve(&styles, "Unknown").is_err()); + assert!(Rules::resolve(&styles, "Nothing").unwrap().is_none()); + styles.mojikumi_tables[0].overrides[0].minimum = 0.4; + assert!(Rules::resolve(&styles, "MojikumiTable/Pair").is_err()); + } + + #[test] + fn chinese_and_half_em_boundaries_differ_and_classes_cover_punctuation() { + let styles = Styles::default(); + let cn = Rules::resolve(&styles, "SimpChineseDefault").unwrap().unwrap(); + let jp = Rules::resolve(&styles, "LineEndAllOneHalfEmEnum").unwrap().unwrap(); + assert_eq!(cn.pair(22, class('(')).desired, 0.5); + assert_eq!(jp.pair(22, class('(')).desired, 0.0); + assert_eq!(cn.pair(class('。'), 22).desired, 0.5); + assert_eq!(jp.pair(class('。'), 22).desired, 0.0); + assert_ne!(class('1'), class('1')); + assert_ne!(class(','), class('、')); + assert_ne!(class('「'), class('(')); + } +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index a979c7c4f..c45775d98 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -105,6 +105,7 @@ struct Importer<'r> { stroke_styles: Vec, styles: Styles, composite_names: HashMap, + mojikumi_names: HashMap, kinsoku: HashMap, para_names: HashMap, char_names: HashMap, @@ -190,6 +191,7 @@ impl<'r> Importer<'r> { stroke_styles: Vec::new(), styles, composite_names: HashMap::new(), + mojikumi_names: HashMap::new(), kinsoku: HashMap::new(), para_names: HashMap::new(), char_names: HashMap::new(), @@ -311,6 +313,9 @@ impl<'r> Importer<'r> { } } for e in top.iter().filter(|e| e.local() == "MojikumiTable") { + if let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) { + self.mojikumi_names.insert(id.into(), format!("MojikumiTable/{}", unescape_id(name))); + } let overrides = e .prop_el("OverrideMojikumiAkiList") .map(|l| { @@ -1185,7 +1190,7 @@ impl<'r> Importer<'r> { "KinsokuHangForce" => Some(designcraft_doc::cjk::KinsokuHang::Force), _ => None, }); - a.mojikumi = e.prop("Mojikumi"); + a.mojikumi = e.prop("Mojikumi").map(|name| self.mojikumi_names.get(&name).cloned().unwrap_or_else(|| unescape_id(&name))); a.kinsoku_type = e.prop("KinsokuType"); a.bunri_kinshi = e.boolean("BunriKinshi"); a.rensuuji = e.boolean("Rensuuji"); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 5fd42f54e..651582ac4 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -792,12 +792,11 @@ fn cjk_composite_fonts_and_custom_kinsoku_are_document_resources() { } #[test] -fn cjk_unsupported_mojikumi_is_preserved_instead_of_silently_dropped() { - let map = DESIGNMAP.replace("", r#" +fn cjk_mojikumi_overrides_and_priority_round_trip() { + let map = DESIGNMAP.replace("", r#" "#); - let story = - STORY.replace(" Date: Thu, 8 Oct 2026 19:54:22 +0800 Subject: [PATCH 03/12] CJK-MOJI2: apply Mojikumi spacing and kinsoku priorities during composition --- ROADMAP.md | 2 + crates/compose/src/breaker.rs | 134 ++++++++++++++++++--- crates/compose/src/lib.rs | 19 ++- crates/compose/src/mojikumi.rs | 178 +++++++++++++++++++++++++++ crates/compose/src/shape.rs | 9 ++ crates/compose/src/tests.rs | 214 +++++++++++++++++++++++++++++++++ 6 files changed, 533 insertions(+), 23 deletions(-) create mode 100644 crates/compose/src/mojikumi.rs diff --git a/ROADMAP.md b/ROADMAP.md index 2531871ea..77cfe7fd3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- CJK-MOJI2: share Mojikumi pair and line-edge metrics between breaking and placement; honor spacing bounds, compression priorities, explicit aki and kinsoku policies. + - CJK-MOJI1: resolve Chinese and half-em Mojikumi presets and directional custom overrides; preserve IDML table references independently of display names. - FONT7: replacement recommendations check the characters actually used; placeholder LastResort fonts and glyph-zero mappings cannot masquerade as glyph coverage or suppress missing-glyph diagnostics. diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 3b08e1050..8aec5df04 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -50,6 +50,8 @@ pub struct Spacing { pub optical: bool, /// Korean text breaks between syllables instead of at spaces. pub korean_char_breaks: bool, + /// 0: minimum adjustment; 1: push in; 2: push out first; 3: push out only. + pub kinsoku_priority: u8, } impl Default for Spacing { @@ -71,6 +73,7 @@ impl Default for Spacing { hyph_zone: 0.0, optical: false, korean_char_breaks: false, + kinsoku_priority: 0, } } } @@ -78,13 +81,18 @@ impl Default for Spacing { impl Spacing { /// Per-tier (word, letter, glyph) stretch and shrink of glyph `g` as a box. fn box_elastic(&self, g: &Glyph) -> ([f64; 3], [f64; 3]) { - if !self.justify || g.locked_advance { + if g.locked_advance { return ([0.0; 3], [0.0; 3]); } + let moji_stretch = if self.justify { g.moji.stretch } else { 0.0 }; + let moji_shrink = if self.kinsoku_priority == 3 { 0.0 } else { g.moji.shrink }; + if !self.justify { + return ([0.0; 3], [moji_shrink, 0.0, 0.0]); + } let natural = g.adv / self.glyph_desired.max(0.01); ( - [0.0, g.space * (self.letter_max - self.letter_desired).max(0.0), natural * (self.glyph_max - self.glyph_desired).max(0.0)], - [0.0, g.space * (self.letter_desired - self.letter_min).max(0.0), natural * (self.glyph_desired - self.glyph_min).max(0.0)], + [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)], ) } /// Word-space stretch and shrink of space glyph `g` (only U+0020 is elastic). @@ -146,6 +154,8 @@ struct Item { auto: bool, /// Breaks: optical hang of the glyph ending the line. Boxes: hang of the glyph starting one. hang: f64, + edge_elastic: [f64; 2], + start_elastic: [f64; 2], /// Auto hyphenation points: index of the break opportunity before the word (or `NONE`). zone_from: usize, } @@ -154,13 +164,49 @@ const NONE: usize = usize::MAX; impl Item { fn boxed(w: f64, st: [f64; 3], sh: [f64; 3]) -> Item { - Item { kind: Kind::Box, w, st, sh, p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE } + Item { + kind: Kind::Box, + w, + st, + sh, + p: 0.0, + flagged: false, + auto: false, + hang: 0.0, + edge_elastic: [0.0; 2], + start_elastic: [0.0; 2], + zone_from: NONE, + } } fn glue(w: f64, st: f64, sh: f64) -> Item { - Item { kind: Kind::Glue, w, st: [st, 0.0, 0.0], sh: [sh, 0.0, 0.0], p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE } + Item { + kind: Kind::Glue, + w, + st: [st, 0.0, 0.0], + sh: [sh, 0.0, 0.0], + p: 0.0, + flagged: false, + auto: false, + hang: 0.0, + edge_elastic: [0.0; 2], + start_elastic: [0.0; 2], + zone_from: NONE, + } } fn penalty(w: f64, p: f64, flagged: bool) -> Item { - Item { kind: Kind::Penalty, w, st: [0.0; 3], sh: [0.0; 3], p, flagged, auto: false, hang: 0.0, zone_from: NONE } + Item { + kind: Kind::Penalty, + w, + st: [0.0; 3], + sh: [0.0; 3], + p, + flagged, + auto: false, + hang: 0.0, + edge_elastic: [0.0; 2], + start_elastic: [0.0; 2], + zone_from: NONE, + } } } @@ -235,7 +281,7 @@ fn hang_left(g: &Glyph) -> f64 { } fn right_hang(g: &Glyph, sp: &Spacing) -> f64 { - g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 }) + g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 }) - g.moji.end } fn hang_right(g: &Glyph) -> f64 { @@ -287,6 +333,9 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec ig.push(i); let mut p = Item::penalty(0.0, -INF, false); p.hang = prev_hang; + if let Some(prev) = i.checked_sub(1).and_then(|j| glyphs.get(j)) { + p.edge_elastic = crate::mojikumi::end_elastic(prev, sp.justify, sp.kinsoku_priority != 3); + } it.push(p); ig.push(i + 1); last_space = NONE; @@ -298,6 +347,9 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec let (st, sh) = sp.space_elastic(g); let mut gl = Item::glue(w, st, sh); gl.hang = prev_hang; + if let Some(prev) = i.checked_sub(1).and_then(|j| glyphs.get(j)) { + gl.edge_elastic = crate::mojikumi::end_elastic(prev, sp.justify, sp.kinsoku_priority != 3); + } last_space = it.len(); it.push(gl); ig.push(i); @@ -307,6 +359,9 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec ig.push(i); let mut p = Item::penalty(0.0, 0.0, false); p.hang = prev_hang; + if let Some(prev) = i.checked_sub(1).and_then(|j| glyphs.get(j)) { + p.edge_elastic = crate::mojikumi::end_elastic(prev, sp.justify, sp.kinsoku_priority != 3); + } last_space = it.len(); it.push(p); ig.push(i); @@ -328,9 +383,8 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec } let (st, sh) = sp.box_elastic(g); let mut bx = Item::boxed(g.adv, st, sh); - if sp.optical { - bx.hang = hang_left(g); - } + bx.hang = (if sp.optical { hang_left(g) } else { 0.0 }) - g.moji.start; + bx.start_elastic = crate::mojikumi::start_elastic(g, sp.justify, sp.kinsoku_priority != 3); it.push(bx); ig.push(i); let next_is_space = glyphs.get(i + 1).is_none_or(|n| n.is_space()); @@ -338,10 +392,12 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec if matches!(g.ch, '-' | '\u{2010}') { let mut p = Item::penalty(0.0, 50.0, true); p.hang = right_hang(g, sp); + p.edge_elastic = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3); 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(g, &glyphs[i + 1], sp)) { let mut p = Item::penalty(0.0, 0.0, false); p.hang = right_hang(g, sp); + p.edge_elastic = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3); push_inword(&mut it, &mut ig, p, i + 1, sp); } else if hyph_after[i] { let hw = hyphen_width(g, &mut hy_cache); @@ -360,6 +416,9 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec ig.push(n); let mut p = Item::penalty(0.0, -INF, false); p.hang = if n > 0 { right_hang(&glyphs[n - 1], sp) } else { 0.0 }; + if let Some(g) = glyphs.last() { + p.edge_elastic = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3); + } it.push(p); ig.push(n); (it, ig) @@ -422,9 +481,21 @@ fn kp_pass( sz[i + 1][t] = sz[i][t] + z[t]; } } - let first_hang = items.iter().find(|i| i.kind == Kind::Box).map_or(0.0, |b| b.hang); - let mut arena: Vec = - vec![Node { pos: 0, line: 0, fitness: 1, tw: first_hang, ty: [0.0; 3], tz: [0.0; 3], demerits: 0.0, prev: None, hyphens: 0, flagged: false }]; + let first_box = items.iter().find(|i| i.kind == Kind::Box); + let first_hang = first_box.map_or(0.0, |b| b.hang); + let first_elastic = first_box.map_or([0.0; 2], |b| b.start_elastic); + let mut arena: Vec = vec![Node { + pos: 0, + line: 0, + fitness: 1, + tw: first_hang, + ty: [-first_elastic[0], 0.0, 0.0], + tz: [-first_elastic[1], 0.0, 0.0], + demerits: 0.0, + prev: None, + hyphens: 0, + flagged: false, + }]; let limit = sp.hyphen_limit as usize; let states = if limit == 0 { 2 } else { (limit + 1).min(MAX_HYPHEN_STATES) }; let mut active: Vec = vec![0]; @@ -454,6 +525,8 @@ fn kp_pass( y[t] = sy[b][t] - n.ty[t]; z[t] = sz[b][t] - n.tz[t]; } + y[0] = (y[0] + it.edge_elastic[0]).max(0.0); + z[0] = (z[0] + it.edge_elastic[1]).max(0.0); let (r, feasible) = tiered_ratio(target - l, y, z); if feasible && !forced { keep.push(a); @@ -477,7 +550,12 @@ fn kp_pass( } } } - let bad = 100.0 * r.abs().powi(3); + let preference = match sp.kinsoku_priority { + 1 if r > 0.0 => 4.0, + 2 if r < 0.0 => 4.0, + _ => 1.0, + }; + let bad = preference * 100.0 * r.abs().powi(3); let lp = 10.0 + bad; let mut d = if pp >= 0.0 { lp * lp + pp * pp @@ -531,6 +609,8 @@ fn kp_pass( match x.kind { Kind::Box => { tw += x.hang; + ty[0] -= x.start_elastic[0]; + tz[0] -= x.start_elastic[1]; break; } Kind::Glue => { @@ -641,8 +721,9 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F let may_hyphenate = |hyphens: u32| sp.hyphen_limit == 0 || hyphens < sp.hyphen_limit; while start < n { let w = width(line).max(1.0); - let mut x = if sp.optical { -hang_left(&glyphs[start]) } else { 0.0 }; - let mut shrink = 0.0; + let mut x = (if sp.optical { -hang_left(&glyphs[start]) } else { 0.0 }) + glyphs[start].moji.start; + let mut shrink = crate::mojikumi::start_elastic(&glyphs[start], sp.justify, sp.kinsoku_priority != 3)[1]; + let mut last_natural: Option<(usize, bool)> = None; let mut last_ok: Option<(usize, bool)> = None; // (break position = end glyph, hyphen) let mut last_space: Option<(usize, f64)> = None; // (break position, line width there) let mut i = start; @@ -658,13 +739,25 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F shrink += sp.space_elastic(g).1; } last_ok = Some((i, false)); + if x <= w { + last_natural = last_ok; + } last_space = Some((i, x)); x += g.adv; i += 1; continue; } let hang_r = right_hang(g, sp); - if x + g.adv - hang_r > w + shrink + let end_shrink = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3)[1]; + let available = shrink + sp.box_elastic(g).1.iter().sum::() + end_shrink; + if sp.kinsoku_priority == 2 + && x + g.adv - hang_r > w + && let Some((end, hyphen)) = last_natural + { + brk = Some((end, hyphen, false)); + break; + } + if x + g.adv - hang_r > w + available.max(0.0) && i > start && (last_ok.is_some() || glyphs.get(i - 1).is_none_or(|p| p.break_after != Some(false) && !p.no_break)) { @@ -680,14 +773,15 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F break; } x += g.adv; - if sp.justify { - shrink += sp.box_elastic(g).1.iter().sum::(); - } + shrink += sp.box_elastic(g).1.iter().sum::(); 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(g, &glyphs[i + 1], sp)) { last_ok = Some((i + 1, false)); + if x - hang_r <= w { + last_natural = last_ok; + } } else if hyph_after[i] && may_hyphenate(hyphens) { let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 }; if x + hy <= w + shrink { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 1ad7d0130..947ec97f6 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -14,6 +14,7 @@ mod bidi; pub mod breaker; mod cache; pub mod hyphen; +mod mojikumi; mod notes; mod overlay; mod ruby; @@ -616,6 +617,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // The same spacing, hyphenation and breaker as the layout below. let mut gl = sp.glyphs.clone(); apply_desired_spacing(&mut gl, &pp); + mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi); let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { breaker::greedy(&gl, &hy, &spacing, &width) @@ -692,6 +694,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C ); bidi::resolve_mirroring(&mut glyphs, &bidi_info); apply_desired_spacing(&mut glyphs, &pp); + mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi); let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); let base_size = base_chars.size; let base_leading = match base_chars.leading { @@ -1166,6 +1169,12 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone }, optical: pp.optical_margin, korean_char_breaks: pp.korean_char_breaks, + kinsoku_priority: match pp.kinsoku_type.as_str() { + "KinsokuPushInFirst" => 1, + "KinsokuPushOutFirst" => 2, + "KinsokuPushOutOnly" => 3, + _ => 0, + }, } } @@ -1568,6 +1577,7 @@ fn layout_line( bidi_info: &unicode_bidi::BidiInfo<'_>, ) -> (Vec, f64, f64) { let mut line: Vec = glyphs[s..e.max(s)].to_vec(); + mojikumi::edges(&mut line); let reference = line.iter().max_by(|a, b| a.size.total_cmp(&b.size)).cloned(); if let Some(reference) = reference { for g in &mut line { @@ -1658,7 +1668,7 @@ fn layout_line( }; let justify_this = align.is_justified() && (!last || align == Align::FullyJustified || forced_mid) && !has_tab; // A justified paragraph's last line may have been composed with shrunk spaces: shrink it too. - let squeeze_last = align.is_justified() && !justify_this && !has_tab && extra < 0.0 && !spaces.is_empty(); + let squeeze_last = !justify_this && !has_tab && extra < 0.0 && (align.is_justified() || line.iter().any(|g| g.moji.shrink > 0.0)); // Extra advance per glyph (word spaces and letter gaps) and horizontal scale per glyph. let mut add = vec![0.0; line.len()]; let mut scale = vec![1.0; line.len()]; @@ -1677,6 +1687,9 @@ fn layout_line( } } } + if (justify_this || squeeze_last) && (extra >= 0.0 || sp.kinsoku_priority != 3) { + extra = mojikumi::distribute(&mut line, extra, &mut add); + } if (justify_this || squeeze_last) && !spaces.is_empty() { distribute(&line, &spaces, extra, sp, &mut add, &mut scale); } else if justify_this && spaces.is_empty() && line.len() > 1 && !last { @@ -1687,13 +1700,13 @@ fn layout_line( .iter() .enumerate() .take(line.len() - 1) - .filter(|(_, g)| !g.locked_advance || g.break_after != Some(false)) + .filter(|(_, g)| !g.moji.active && (!g.locked_advance || g.break_after != Some(false))) .map(|(i, _)| i) .collect(); if !gaps.is_empty() { let per = extra / gaps.len() as f64; for i in gaps { - add[i] = per; + add[i] += per; } } } diff --git a/crates/compose/src/mojikumi.rs b/crates/compose/src/mojikumi.rs new file mode 100644 index 000000000..339b60856 --- /dev/null +++ b/crates/compose/src/mojikumi.rs @@ -0,0 +1,178 @@ +//! Mojikumi boundary widths. The same values feed line selection and placement. +use crate::shape::Glyph; +use designcraft_doc::{ + Styles, + mojikumi::{self as rules, Aki, Rules}, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct Metrics { + pub active: bool, + /// Desired internal gap attached to this glyph's advance. + pub gap: f64, + pub stretch: f64, + pub shrink: f64, + pub priority: u8, + pub discrete: bool, + /// Width changes when this glyph starts / ends a line. + pub start: f64, + pub end: f64, + pub end_aki: Aki, + pub start_aki: Aki, + pub at_start: bool, + pub explicit_before: bool, + pub explicit_after: bool, +} + +fn eligible(g: &Glyph) -> bool { + g.adv > 0.0 + && !g.locked_advance + && !g.ch.is_control() + && (!g.ch.is_whitespace() || g.ch == '\u{3000}') + && !matches!(g.ch, '\u{e000}'..='\u{e1ff}') +} +fn em(g: &Glyph) -> f64 { + // Actual scaled em, including vertical and composite-font scaling. + g.face.units_per_em() * if g.upright { g.sy } else { g.sx } +} + +pub fn apply(glyphs: &mut [Glyph], styles: &Styles, name: &str) { + let Ok(Some(table)) = Rules::resolve(styles, name) else { return }; + // Remove punctuation's built-in blank half-body. The table then contributes + // the desired spacing once per boundary, rather than adding it twice. + for g in glyphs.iter_mut().filter(|g| eligible(g)) { + g.moji.active = true; + let class = rules::class(g.rendered_char); + let unit = em(g); + if g.adv >= unit * 0.8 { + let before = if g.moji.explicit_before { 0.0 } else { rules::leading(class) * unit }; + let after = if g.moji.explicit_after { 0.0 } else { rules::trailing(class) * unit }; + g.adv = (g.adv - before - after).max(0.0); + g.dx -= before; + } + } + for i in 0..glyphs.len() { + let (left, right) = glyphs.split_at_mut(i + 1); + let Some(g) = left.last_mut() else { continue }; + if !g.moji.active { + continue; + } + let c = rules::class(g.rendered_char); + let unit = em(g); + let start = table.pair(if i == 0 { 23 } else { 22 }, c); + g.moji.start_aki = start; + g.moji.start = if g.moji.explicit_before { 0.0 } else { start.desired * unit }; + g.moji.end_aki = table.pair(c, 22); + let mut gap = Aki::default(); + if let Some(next) = right.first().filter(|n| n.moji.active && n.byte != g.byte) + && !g.moji.explicit_after + && !next.moji.explicit_before + { + gap = table.pair(c, rules::class(next.rendered_char)); + } + let desired = (gap.desired * unit).max(-g.adv); + g.moji.gap = desired; + g.moji.stretch = (gap.max * unit - desired).max(0.0); + g.moji.shrink = (desired - gap.min * unit).max(0.0).min(g.adv + desired); + g.moji.priority = gap.priority; + g.moji.discrete = gap.discrete; + g.moji.end = if g.moji.explicit_after { 0.0 } else { g.moji.end_aki.desired * unit } - desired; + g.adv += desired; + } +} + +pub fn edges(line: &mut [Glyph]) { + if let Some(g) = line.first_mut() { + g.moji.at_start = true; + g.adv += g.moji.start; + g.dx += g.moji.start; + } + if let Some(g) = line.iter_mut().rev().find(|g| !crate::breaker::is_forced(g.ch)) { + g.adv += g.moji.end; + // A line-end gap is not the internal gap that happened to follow it. + let unit = em(g); + let aki = g.moji.end_aki; + g.moji.stretch = if g.moji.explicit_after { 0.0 } else { (aki.max - aki.desired).max(0.0) * unit }; + g.moji.shrink = if g.moji.explicit_after { 0.0 } else { (aki.desired - aki.min).max(0.0) * unit }; + g.moji.priority = aki.priority; + g.moji.discrete = aki.discrete; + } +} + +/// Consume spacing in priority order; discrete rules use an entire endpoint. +/// Returns the residual for ordinary word/letter/glyph justification. +pub fn distribute(line: &mut [Glyph], extra: f64, add: &mut [f64]) -> f64 { + let mut rem = extra.abs(); + let sign = extra.signum(); + for priority in 0..=10 { + // Start and following gaps share the same priority queue. Moving a + // leading gap also moves the glyph outline, not just the next glyph. + for before in [true, false] { + let properties = |g: &Glyph| { + if before { + let a = g.moji.start_aki; + let cap = if !g.moji.at_start || g.moji.explicit_before { + 0.0 + } else if sign < 0.0 { + (a.desired - a.min).max(0.0) * em(g) + } else { + (a.max - a.desired).max(0.0) * em(g) + }; + (a.priority, a.discrete, cap) + } else { + (g.moji.priority, g.moji.discrete, if sign < 0.0 { g.moji.shrink } else { g.moji.stretch }) + } + }; + for (g, a) in line.iter_mut().zip(add.iter_mut()) { + let (p, discrete, cap) = properties(g); + if p == priority && discrete && cap <= rem + 1e-9 { + *a += sign * cap; + if before { + g.dx += sign * cap; + } + rem = (rem - cap).max(0.0); + } + } + let total: f64 = line + .iter() + .map(|g| { + let (p, discrete, cap) = properties(g); + if p == priority && !discrete { cap } else { 0.0 } + }) + .sum(); + if total > 0.0 { + let take = rem.min(total); + for (g, a) in line.iter_mut().zip(add.iter_mut()) { + let (p, discrete, cap) = properties(g); + if p == priority && !discrete { + let amount = sign * take * cap / total; + *a += amount; + if before { + g.dx += amount; + } + } + } + rem -= take; + } + } + } + sign * rem +} + +/// Replace the final internal gap's elasticity by its line-end elasticity. +pub fn end_elastic(g: &Glyph, justify: bool, shrink: bool) -> [f64; 2] { + let unit = em(g); + let a = g.moji.end_aki; + [ + if justify { (if g.moji.explicit_after { 0.0 } else { (a.max - a.desired).max(0.0) * unit }) - g.moji.stretch } else { 0.0 }, + if shrink { (if g.moji.explicit_after { 0.0 } else { (a.desired - a.min).max(0.0) * unit }) - g.moji.shrink } else { 0.0 }, + ] +} + +pub fn start_elastic(g: &Glyph, justify: bool, shrink: bool) -> [f64; 2] { + let a = g.moji.start_aki; + if g.moji.explicit_before { + return [0.0; 2]; + } + [if justify { (a.max - a.desired).max(0.0) * em(g) } else { 0.0 }, if shrink { (a.desired - a.min).max(0.0) * em(g) } else { 0.0 }] +} diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index e698d76ab..9391f3a19 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -49,6 +49,7 @@ pub struct Glyph { /// Resolved CJK boundary constraint, independent of glyph outlines. pub break_after: Option, pub cjk_hang: f64, + pub moji: crate::mojikumi::Metrics, pub ideographic_space_elastic: bool, pub character_alignment: designcraft_doc::cjk::CharacterAlignment, pub leading_model: designcraft_doc::cjk::LeadingModel, @@ -803,6 +804,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty space: face.advance(face.glyph_for(' ')) * k * p.h_scale, break_after: None, cjk_hang: 0.0, + moji: crate::mojikumi::Metrics { explicit_before: p.leading_aki.is_some(), explicit_after: p.trailing_aki.is_some(), ..Default::default() }, ideographic_space_elastic: false, character_alignment: p.character_alignment, leading_model: p.leading_model, @@ -947,6 +949,11 @@ fn shape_segment( space, break_after: None, cjk_hang: 0.0, + moji: crate::mojikumi::Metrics { + explicit_before: p.leading_aki.is_some(), + explicit_after: p.trailing_aki.is_some(), + ..Default::default() + }, ideographic_space_elastic: false, character_alignment: p.character_alignment, leading_model: p.leading_model, @@ -994,5 +1001,7 @@ pub(crate) fn hyphen_after(g: &Glyph) -> Glyph { h.byte = g.byte + g.len; h.len = 0; h.ch = '-'; + h.rendered_char = '-'; + h.moji = Default::default(); h } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 417adc61d..b9589b060 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2014,3 +2014,217 @@ fn generated_note_labels_use_coverage_fallback() { assert_ne!(glyph.gid, 0); assert_eq!(glyph.face.family, "Generated Label Coverage"); } + +#[test] +fn mojikumi_custom_pairs_change_positions_and_breaks_without_font_scaling() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with( + "A1A1A1", + Rect::new(0.0, 0.0, 55.0, 300.0), + ParaAttrs { hyphenate: Some(false), composer: Some(Composer::SingleLine), ..Default::default() }, + ); + let before = compose_story(&d, sid, &ComposeOptions::default()); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Synthetic".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![ + MojikumiAki { target_class: 18, side_class: 25, after: true, minimum: 1.0, desired: 1.0, maximum: 1.0, ..Default::default() }, + MojikumiAki { target_class: 25, side_class: 18, after: true, minimum: 1.0, desired: 1.0, maximum: 1.0, ..Default::default() }, + ], + }); + d.story_mut(sid).unwrap().paras[0].para.mojikumi = Some("MojikumiTable/Synthetic".into()); + let after = compose_story(&d, sid, &ComposeOptions::default()); + assert!(all_lines(&after).len() > all_lines(&before).len()); + let a = &all_lines(&after)[0].glyphs; + let b = &all_lines(&before)[0].glyphs; + assert!((a[1].x - b[1].x - 12.0).abs() < 0.001); + assert_eq!(a[0].sx, b[0].sx); + assert!(all_lines(&after).iter().all(|l| l.end_x <= l.x1 + 0.01)); +} + +#[test] +fn mojikumi_paragraph_indent_adds_to_paragraph_settings_and_rounds_no_glyphs() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with( + "ABC", + Rect::new(0.0, 0.0, 200.0, 100.0), + ParaAttrs { first_line_indent: Some(7.0), mojikumi: Some("MojikumiTable/Indent".into()), ..Default::default() }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Indent".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 18, + side_class: 23, + after: false, + minimum: 2.0, + desired: 2.0, + maximum: 2.0, + ..Default::default() + }], + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&cs)[0]; + assert!((line.glyphs[0].x - 31.0).abs() < 0.001, "{}", line.glyphs[0].x); + assert_eq!(line.glyphs.len(), 3); +} + +#[test] +fn mojikumi_justifies_cjk_without_spaces_and_respects_fixed_spacing() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with( + "A1A1", + Rect::new(0.0, 0.0, 100.0, 100.0), + ParaAttrs { align: Some(Align::FullyJustified), mojikumi: Some("MojikumiTable/Elastic".into()), ..Default::default() }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Elastic".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 18, + side_class: 25, + after: true, + minimum: 0.0, + desired: 0.0, + maximum: 5.0, + priority: 1, + ..Default::default() + }], + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!((all_lines(&cs)[0].end_x - 100.0).abs() < 0.001, "{}", all_lines(&cs)[0].end_x); +} + +#[test] +fn mojikumi_fixed_pairs_are_not_stretched_by_single_word_justification() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with( + "A1A1", + Rect::new(0.0, 0.0, 150.0, 100.0), + ParaAttrs { align: Some(Align::FullyJustified), mojikumi: Some("MojikumiTable/Fixed".into()), ..Default::default() }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Fixed".into(), + based_on: "SimpChineseDefault".into(), + overrides: [(18, 25), (25, 18)] + .into_iter() + .map(|(target_class, side_class)| MojikumiAki { + target_class, + side_class, + after: true, + minimum: 0.25, + desired: 0.25, + maximum: 0.25, + ..Default::default() + }) + .collect(), + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&cs)[0]; + assert!(line.end_x < 100.0, "Fixed spacing was stretched: {}", line.end_x); + d.story_mut(sid).unwrap().paras[0].para.align = Some(Align::Left); + let ragged = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(line.end_x, all_lines(&ragged)[0].end_x); +} + +#[test] +fn mojikumi_kinsoku_push_in_and_push_out_select_different_legal_breaks() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let text = "甲乙丙丁戊己庚辛"; + let (mut d, sid, _) = doc_with( + text, + Rect::new(0.0, 0.0, 45.0, 300.0), + ParaAttrs { + composer: Some(Composer::SingleLine), + mojikumi: Some("MojikumiTable/Compress".into()), + kinsoku_type: Some("KinsokuPushInFirst".into()), + ..Default::default() + }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Compress".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 12, + side_class: 12, + after: true, + minimum: 0.0, + desired: 1.0, + maximum: 1.0, + priority: 1, + ..Default::default() + }], + }); + let inside = compose_story(&d, sid, &ComposeOptions::default()); + d.story_mut(sid).unwrap().paras[0].para.kinsoku_type = Some("KinsokuPushOutFirst".into()); + let outside = compose_story(&d, sid, &ComposeOptions::default()); + assert!(all_lines(&inside)[0].range.end > all_lines(&outside)[0].range.end); + for cs in [&inside, &outside] { + assert!(all_lines(cs).iter().all(|l| l.end_x <= l.x1 + 0.01)); + } +} + +#[test] +fn mojikumi_priority_and_elastic_line_start_are_used_in_placement() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with("A1B2", Rect::new(0.0, 0.0, 45.0, 100.0), ParaAttrs::default()); + let natural = compose_story(&d, sid, &ComposeOptions::default()); + let middle_gap = all_lines(&natural)[0].glyphs[2].x - all_lines(&natural)[0].glyphs[1].x; + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Priority".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![ + MojikumiAki { + target_class: 18, + side_class: 25, + after: true, + minimum: 0.0, + desired: 0.0, + maximum: 10.0, + priority: 1, + ..Default::default() + }, + MojikumiAki { + target_class: 25, + side_class: 18, + after: true, + minimum: 0.0, + desired: 0.0, + maximum: 10.0, + priority: 2, + ..Default::default() + }, + ], + }); + let para = &mut d.story_mut(sid).unwrap().paras[0].para; + para.mojikumi = Some("MojikumiTable/Priority".into()); + para.align = Some(Align::FullyJustified); + let result = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&result)[0]; + assert!((line.end_x - 45.0).abs() < 0.001); + assert!((line.glyphs[2].x - line.glyphs[1].x - middle_gap).abs() < 0.001, "Priority 2 was used before priority 1 was exhausted"); + + let (mut d, sid, _) = doc_with( + "A", + Rect::new(0.0, 0.0, 30.0, 100.0), + ParaAttrs { mojikumi: Some("MojikumiTable/Start".into()), align: Some(Align::FullyJustified), ..Default::default() }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Start".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 18, + side_class: 23, + after: false, + minimum: 0.0, + desired: 0.0, + maximum: 5.0, + priority: 1, + ..Default::default() + }], + }); + let result = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&result)[0]; + assert!((line.end_x - 30.0).abs() < 0.001); + assert!(line.glyphs[0].x > 10.0, "Leading gap must move the first outline too"); +} From d16a7423b877fe7983626bd1c83df527ad08c900 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 19:55:16 +0800 Subject: [PATCH 04/12] CJK-MOJI3: report only unsupported Mojikumi rules in preflight --- ROADMAP.md | 2 ++ crates/engine/src/cmd/preflight.rs | 9 ++++++--- crates/engine/src/tests.rs | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 77cfe7fd3..961368d44 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- CJK-MOJI3: use the composition rule resolver in preflight, report unknown Mojikumi presets and retain regression coverage for supported and unsupported settings. + - CJK-MOJI2: share Mojikumi pair and line-edge metrics between breaking and placement; honor spacing bounds, compression priorities, explicit aki and kinsoku policies. - CJK-MOJI1: resolve Chinese and half-em Mojikumi presets and directional custom overrides; preserve IDML table references independently of display names. diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 25b9d45fd..22a60fa2c 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -155,10 +155,13 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { .insert("Paragraph Kashida width preset is preserved; automatic elongation uses the engine's bounded allocation".into()); } } - if !matches!(para.mojikumi.as_str(), "" | "Nothing" | "None") { - unsupported_typography.insert(format!("Mojikumi `{}` is preserved, but its spacing table is not applied", para.mojikumi)); + if let Err(reason) = designcraft_doc::mojikumi::Rules::resolve(&d.styles, ¶.mojikumi) { + unsupported_typography.insert(format!("Mojikumi `{}` is not applied: {reason}", para.mojikumi)); } - if !para.kinsoku_type.is_empty() { + if !matches!( + para.kinsoku_type.as_str(), + "" | "KinsokuPushInFirst" | "KinsokuPushOutFirst" | "KinsokuPushOutOnly" | "KinsokuPrioritizeAdjustmentAmount" + ) { unsupported_typography .insert(format!("Kinsoku priority `{}` is preserved, but push-in/push-out priority is not applied", para.kinsoku_type)); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4baaa3e4f..f29462053 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -565,3 +565,17 @@ fn preflight_distinguishes_unplaced_stories_and_locates_footnote_overset() { assert_eq!(unplaced["severity"], "warning"); assert!(unplaced.get("page").is_none()); } + +#[test] +fn preflight_mojikumi_uses_the_same_supported_rules_as_composition() { + let mut s = session(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "中文 A1"})).unwrap(); + let story = s.doc().unwrap().doc.stories.keys().next().unwrap().0; + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 1})).unwrap(); + for (name, unsupported) in [("SimpChineseDefault", false), ("LineEndAllOneHalfEmEnum", false), ("UnknownPreset", true), ("Nothing", false)] { + s.execute("type.para", &json!({"attrs": {"mojikumi": name, "kinsokuType": "KinsokuPushInFirst"}})).unwrap(); + let result = s.execute("preflight.run", &json!({})).unwrap(); + let warnings: Vec<_> = result["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "unsupportedTypography").collect(); + assert_eq!(!warnings.is_empty(), unsupported, "{name}: {warnings:?}"); + } +} From 14b04cbaf8954aa9f2a28442dce4d2dc5c19be6b Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 20:01:57 +0800 Subject: [PATCH 05/12] CJK-MOJI4: implement public preset boundary policies and punctuation classes --- ROADMAP.md | 2 + crates/doc/src/mojikumi.rs | 150 ++++++++++++++++++++++++++++++++----- docs/mojikumi.md | 33 ++++++++ 3 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 docs/mojikumi.md diff --git a/ROADMAP.md b/ROADMAP.md index 961368d44..9c452b9ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- CJK-MOJI4: resolve all public Mojikumi preset names and their distinct paragraph/line-edge policies; correct consecutive punctuation defaults and small-kana classes. + - CJK-MOJI3: use the composition rule resolver in preflight, report unknown Mojikumi presets and retain regression coverage for supported and unsupported settings. - CJK-MOJI2: share Mojikumi pair and line-edge metrics between breaking and placement; honor spacing bounds, compression priorities, explicit aki and kinsoku policies. diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index 72056e7ca..bff676c9f 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -19,9 +19,74 @@ impl Aki { } } +/// Adobe's public 16-preset enumeration. Japanese preset geometry is described +/// in the Adobe Mojikumi guide, sections 3.1–3.14 (see docs/mojikumi.md). +#[derive(Clone, Copy, Debug)] +struct Preset(u8); +impl Preset { + fn parse(name: &str) -> Option { + let name = name.trim_start_matches("$ID/"); + let names = [ + "LineEndAllOneHalfEmEnum", + "OneEmIndentLineEndUkeOneHalfEmEnum", + "OneOrOneHalfEmIndentLineEndUkeOneHalfEmEnum", + "OneOrOneHalfEmIndentLineEndAllOneEmEnum", + "OneEmIndentLineEndAllOneEmEnum", + "OneEmIndentLineEndAllNoFloatEnum", + "OneEmIndentLineEndUkeNoFloatEnum", + "OneOrOneHalfEmIndentLineEndUkeNoFloatEnum", + "OneEmIndentLineEndAllOneHalfEmEnum", + "LineEndAllOneEmEnum", + "LineEndUkeNoFloatEnum", + "OneOrOneHalfEmIndentLineEndPeriodOneEmEnum", + "OneEmIndentLineEndPeriodOneEmEnum", + "LineEndPeriodOneEmEnum", + "TradChineseDefault", + "SimpChineseDefault", + ]; + names.iter().position(|n| *n == name).map(|i| Self(i as u8 + 1)).or_else(|| { + let n = name.strip_prefix("kMojikumiDefaultName")?.parse::().ok()?; + (1..=16).contains(&n).then_some(Self(n)) + }) + } + fn boundary(self, left: i16, right: i16) -> Option { + let n = self.0; + let open = |c| matches!(c, 1 | 26 | 27); + if left == 23 { + if n >= 15 { + return Some(Aki::fixed(leading(right))); + } + let indent = if matches!(n, 1 | 10 | 11 | 14) { 0.0 } else { 1.0 }; + let opening = match n { + 2 | 5 | 7 | 13 => 1.5, + 3 | 8 | 10 => 0.5, + 4 | 6 | 9 | 12 => 1.0, + _ => 0.0, + }; + return Some(Aki::fixed(if open(right) { opening } else { indent + leading(right) })); + } + if left == 22 { + return Some(Aki::fixed(if n >= 15 || matches!(n, 4 | 5 | 10) { leading(right) } else { 0.0 })); + } + if right == 22 { + let width = trailing(left); + let full = n >= 15 || matches!(n, 4 | 5 | 10) || (matches!(n, 12..=14) && matches!(left, 6 | 31)); + let discrete = n == 6 || (matches!(n, 7 | 8 | 11) && matches!(left, 2 | 6 | 21 | 28..=31)); + return Some(if full { + Aki::fixed(width) + } else if discrete { + Aki { min: 0.0, desired: width, max: width, priority: 1, discrete: true } + } else { + Aki::fixed(0.0) + }); + } + None + } +} + /// Resolved table; custom rows override the base preset in document order. pub struct Rules<'a> { - chinese: bool, + preset: Preset, rows: &'a [MojikumiAki], } impl<'a> Rules<'a> { @@ -32,11 +97,7 @@ impl<'a> Rules<'a> { let key = name.strip_prefix("MojikumiTable/").unwrap_or(name); let table = styles.mojikumi_tables.iter().find(|t| t.name == key); let base = table.map_or(key, |t| if t.based_on.is_empty() { t.name.as_str() } else { t.based_on.as_str() }); - let chinese = match base.trim_start_matches("$ID/") { - "SimpChineseDefault" | "TradChineseDefault" => true, - "LineEndAllOneHalfEmEnum" | "kMojikumiDefaultName1" => false, - _ => return Err(format!("unknown base preset `{base}`")), - }; + let preset = Preset::parse(base).ok_or_else(|| format!("unknown base preset `{base}`"))?; let rows = table.map_or(&[][..], |t| t.overrides.as_slice()); for r in rows { if !valid_class(r.target_class) || !valid_class(r.side_class) { @@ -50,7 +111,7 @@ impl<'a> Rules<'a> { return Err("invalid spacing range or priority".into()); } } - Ok(Some(Self { chinese, rows })) + Ok(Some(Self { preset, rows })) } pub fn pair(&self, left: i16, right: i16) -> Aki { @@ -68,13 +129,15 @@ impl<'a> Rules<'a> { discrete: row.does_not_float, }; } - // Boundaries: Chinese keeps full punctuation; the half-em Japanese set - // trims opening punctuation at line start and closing punctuation at end. - if matches!(left, 22 | 23) { - return Aki::fixed(if self.chinese { leading(right) } else { 0.0 }); + if let Some(aki) = self.preset.boundary(left, right) { + return aki; } - if right == 22 { - return Aki::fixed(if self.chinese { trailing(left) } else { 0.0 }); + // Adjacent opening brackets and adjacent closing punctuation share a + // half-body, rather than restoring both glyphs to their full widths. + if (matches!(left, 1 | 26 | 27) && matches!(right, 1 | 26 | 27)) + || (matches!(left, 2 | 6 | 21 | 28..=31) && matches!(right, 2 | 6 | 21 | 28..=31)) + { + return Aki::fixed(0.0); } let punctuation = trailing(left).max(leading(right)); if punctuation > 0.0 { @@ -131,13 +194,42 @@ pub fn class(c: char) -> i16 { '¥' | '$' | '£' | '¥' => 8, '%' | '‰' | '℃' | '°' => 9, '\u{3000}' => 10, - 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' - | 'ー' | '々' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' => 3, + 'ゕ' + | 'ゖ' + | 'ヵ' + | 'ヶ' + | '\u{31f0}'..='\u{31ff}' + | 'ぁ' + | 'ぃ' + | 'ぅ' + | 'ぇ' + | 'ぉ' + | 'っ' + | 'ゃ' + | 'ゅ' + | 'ょ' + | 'ゎ' + | 'ァ' + | 'ィ' + | 'ゥ' + | 'ェ' + | 'ォ' + | 'ッ' + | 'ャ' + | 'ュ' + | 'ョ' + | 'ヮ' + | 'ー' + | '々' + | 'ゝ' + | 'ゞ' + | 'ヽ' + | 'ヾ' => 3, '\u{3040}'..='\u{309f}' => 11, - '\u{30a0}'..='\u{30ff}' | '\u{31f0}'..='\u{31ff}' => 33, + '\u{30a0}'..='\u{30ff}' | '\u{ff66}'..='\u{ff9d}' => 33, '0'..='9' => 24, '0'..='9' => 25, - '\u{2e80}'..='\u{a4cf}' | '\u{ac00}'..='\u{d7af}' | '\u{f900}'..='\u{faff}' | '\u{ff01}'..='\u{ff60}' | '\u{20000}'..='\u{323af}' => 12, + '\u{2e80}'..='\u{a4cf}' | '\u{ac00}'..='\u{d7af}' | '\u{f900}'..='\u{faff}' | '\u{ff01}'..='\u{ff60}' | '\u{20000}'..='\u{3347f}' => 12, _ => 18, } } @@ -147,6 +239,30 @@ mod tests { use super::*; use crate::cjk::MojikumiTable; + #[test] + fn all_public_presets_have_distinct_documented_edges() { + let styles = Styles::default(); + let openings = [0.0, 1.5, 0.5, 1.0, 1.5, 1.0, 1.5, 0.5, 1.0, 0.5, 0.0, 1.0, 1.5, 0.0]; + for (i, expected) in openings.into_iter().enumerate() { + let rules = Rules::resolve(&styles, &format!("$ID/kMojikumiDefaultName{}", i + 1)).unwrap().unwrap(); + assert_eq!(rules.pair(23, 26).desired, expected, "preset {}", i + 1); + assert_eq!(rules.pair(23, 12).desired, if matches!(i + 1, 1 | 10 | 11 | 14) { 0.0 } else { 1.0 }); + } + let discrete = Rules::resolve(&styles, "OneEmIndentLineEndUkeNoFloatEnum").unwrap().unwrap(); + assert!(discrete.pair(6, 22).discrete); + assert!(!discrete.pair(5, 22).discrete); + let period = Rules::resolve(&styles, "LineEndPeriodOneEmEnum").unwrap().unwrap(); + assert_eq!(period.pair(6, 22).desired, 0.5); + assert_eq!(period.pair(21, 22).desired, 0.0); + for name in ["$ID/kMojikumiDefaultName15", "$ID/kMojikumiDefaultName16"] { + assert!(Rules::resolve(&styles, name).unwrap().is_some()); + } + assert_eq!(period.pair(26, 26).desired, 0.0); + assert_eq!(period.pair(28, 6).desired, 0.0); + assert_eq!(class('ㇰ'), 3); + assert_eq!(class('カ'), 33); + } + #[test] fn overrides_are_directional_and_unknown_tables_are_not_silently_applied() { let mut styles = Styles::default(); diff --git a/docs/mojikumi.md b/docs/mojikumi.md new file mode 100644 index 000000000..c9390d58d --- /dev/null +++ b/docs/mojikumi.md @@ -0,0 +1,33 @@ +# Mojikumi implementation and public references + +This is a clean-room implementation of public spacing behavior. Mojikumi is an +Adobe feature, not a single conformance standard. W3C JLReq and CLReq describe +Japanese and Chinese layout requirements; they do not specify Adobe's private +composer or its complete preset pair matrices. + +References: + +- [Adobe preset enumeration](https://developer.adobe.com/indesign/uxp/dom/api/m/mojikumi-table-defaults/) +- [Adobe Mojikumi guide, sections 3–5](https://wwwimages.adobe.com/www.adobe.com/jp/joc/design/guides/pdf/mojikumi.pdf) +- [Adobe spacing priorities](https://helpx.adobe.com/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/set-spacing-priorities-in-mojikumi-character-classes.html) +- [W3C JLReq](https://www.w3.org/TR/jlreq/), especially character classes and line adjustment +- [W3C CLReq](https://www.w3.org/TR/clreq/), especially regional punctuation placement + +## Presets and interchange + +The resolver recognizes the 14 Japanese presets and two Chinese presets from the +public enumeration, including their IDML resource aliases. Paragraph indentation, +opening-bracket indentation, ordinary line starts, full/half/discrete line ends, +and period-only line-end policies are represented separately. Custom IDML rows +replace the corresponding directional pair. Adjacent opening and closing marks +must not regain two full blank half-bodies. + +Regression tests cover preset boundary differences, custom override precedence, +IDML reference round trips, and unsupported names. The implementation must not be +represented as a byte-for-byte reproduction of Adobe's full preset matrices. + +## Audit work + +Further audit areas are regional punctuation metrics, shaped cluster boundaries, +equal-priority allocation, discrete line fitting, input validation, and diagnostics +for compatibility modes whose glyph classification cannot yet be reproduced. From a35f03c12639cf1ddc5235b714eb38a24fa9f7c5 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 20:05:19 +0800 Subject: [PATCH 06/12] CJK-MOJI5: balance spacing priorities and fit discrete compression endpoints --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 5 +- crates/compose/src/mojikumi.rs | 85 ++++++++++++++++++---------------- crates/compose/src/tests.rs | 65 ++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 40 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9c452b9ad..1897b38e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # DesignCraft roadmap +- CJK-MOJI5: share equal-priority boundary spacing and honor discrete compression endpoints, including leading-only ragged-line fitting. - CJK-MOJI4: resolve all public Mojikumi preset names and their distinct paragraph/line-edge policies; correct consecutive punctuation defaults and small-kana classes. - CJK-MOJI3: use the composition rule resolver in preflight, report unknown Mojikumi presets and retain regression coverage for supported and unsupported settings. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 947ec97f6..4b9e6f28f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1668,7 +1668,10 @@ fn layout_line( }; let justify_this = align.is_justified() && (!last || align == Align::FullyJustified || forced_mid) && !has_tab; // A justified paragraph's last line may have been composed with shrunk spaces: shrink it too. - let squeeze_last = !justify_this && !has_tab && extra < 0.0 && (align.is_justified() || line.iter().any(|g| g.moji.shrink > 0.0)); + let squeeze_last = !justify_this + && !has_tab + && extra < 0.0 + && (align.is_justified() || line.iter().any(|g| g.moji.shrink > 0.0 || mojikumi::start_elastic(g, false, true)[1] > 0.0)); // Extra advance per glyph (word spaces and letter gaps) and horizontal scale per glyph. let mut add = vec![0.0; line.len()]; let mut scale = vec![1.0; line.len()]; diff --git a/crates/compose/src/mojikumi.rs b/crates/compose/src/mojikumi.rs index 339b60856..23dde00e3 100644 --- a/crates/compose/src/mojikumi.rs +++ b/crates/compose/src/mojikumi.rs @@ -104,46 +104,38 @@ pub fn edges(line: &mut [Glyph]) { pub fn distribute(line: &mut [Glyph], extra: f64, add: &mut [f64]) -> f64 { let mut rem = extra.abs(); let sign = extra.signum(); - for priority in 0..=10 { - // Start and following gaps share the same priority queue. Moving a - // leading gap also moves the glyph outline, not just the next glyph. - for before in [true, false] { - let properties = |g: &Glyph| { - if before { - let a = g.moji.start_aki; - let cap = if !g.moji.at_start || g.moji.explicit_before { - 0.0 - } else if sign < 0.0 { - (a.desired - a.min).max(0.0) * em(g) - } else { - (a.max - a.desired).max(0.0) * em(g) - }; - (a.priority, a.discrete, cap) - } else { - (g.moji.priority, g.moji.discrete, if sign < 0.0 { g.moji.shrink } else { g.moji.stretch }) - } + let properties = |g: &Glyph, before: bool| { + if before { + let a = g.moji.start_aki; + let cap = if !g.moji.at_start || g.moji.explicit_before { + 0.0 + } else if sign < 0.0 { + (a.desired - a.min).max(0.0) * em(g) + } else { + (a.max - a.desired).max(0.0) * em(g) }; + (a.priority, a.discrete, cap) + } else { + (g.moji.priority, g.moji.discrete, if sign < 0.0 { g.moji.shrink } else { g.moji.stretch }) + } + }; + for priority in 0..=10 { + if rem <= 1e-9 { + break; + } + // Leading and following continuous gaps participate in one pool. + // Equal priorities must not depend on whether a gap precedes a glyph. + let total: f64 = line + .iter() + .flat_map(|g| [true, false].map(|before| properties(g, before))) + .filter(|&(p, discrete, _)| p == priority && !discrete) + .map(|(_, _, cap)| cap) + .sum(); + if total > 0.0 { + let take = rem.min(total); for (g, a) in line.iter_mut().zip(add.iter_mut()) { - let (p, discrete, cap) = properties(g); - if p == priority && discrete && cap <= rem + 1e-9 { - *a += sign * cap; - if before { - g.dx += sign * cap; - } - rem = (rem - cap).max(0.0); - } - } - let total: f64 = line - .iter() - .map(|g| { - let (p, discrete, cap) = properties(g); - if p == priority && !discrete { cap } else { 0.0 } - }) - .sum(); - if total > 0.0 { - let take = rem.min(total); - for (g, a) in line.iter_mut().zip(add.iter_mut()) { - let (p, discrete, cap) = properties(g); + for before in [true, false] { + let (p, discrete, cap) = properties(g, before); if p == priority && !discrete { let amount = sign * take * cap / total; *a += amount; @@ -152,7 +144,22 @@ pub fn distribute(line: &mut [Glyph], extra: f64, add: &mut [f64]) -> f64 { } } } - rem -= take; + } + rem -= take; + } + // Never interpolate a non-floating rule. Compression may have to use + // its entire endpoint even when that leaves a little unused line width; + // refusing it would contradict the capacity accepted by the breaker. + for (g, a) in line.iter_mut().zip(add.iter_mut()) { + for before in [true, false] { + let (p, discrete, cap) = properties(g, before); + if rem > 1e-9 && p == priority && discrete && cap > 0.0 && (sign < 0.0 || cap <= rem + 1e-9) { + *a += sign * cap; + if before { + g.dx += sign * cap; + } + rem -= cap; + } } } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b9589b060..e967940d2 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2228,3 +2228,68 @@ fn mojikumi_priority_and_elastic_line_start_are_used_in_placement() { assert!((line.end_x - 30.0).abs() < 0.001); assert!(line.glyphs[0].x > 10.0, "Leading gap must move the first outline too"); } + +#[test] +fn mojikumi_equal_priority_shares_leading_and_internal_space() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with("A1", Rect::new(0.0, 0.0, 40.0, 100.0), ParaAttrs::default()); + let natural = compose_story(&d, sid, &ComposeOptions::default()); + let old = all_lines(&natural)[0]; + let extra = 40.0 - old.end_x; + let old_second = old.glyphs[1].x; + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Shared".into(), + based_on: "SimpChineseDefault".into(), + overrides: [(18, 23, false), (18, 25, true)] + .into_iter() + .map(|(target_class, side_class, after)| MojikumiAki { + target_class, + side_class, + after, + maximum: 10.0, + priority: 1, + ..Default::default() + }) + .collect(), + }); + let para = &mut d.story_mut(sid).unwrap().paras[0].para; + para.mojikumi = Some("MojikumiTable/Shared".into()); + para.align = Some(Align::FullyJustified); + let result = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&result)[0]; + assert!((line.glyphs[0].x - extra / 2.0).abs() < 0.001); + assert!((line.glyphs[1].x - old_second - extra).abs() < 0.001); + assert!((line.end_x - 40.0).abs() < 0.001); +} + +#[test] +fn mojikumi_nonfloating_and_leading_only_compression_fit_ragged_lines() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + for composer in [Composer::SingleLine, Composer::Paragraph] { + for leading in [true, false] { + let (mut d, sid, _) = doc_with( + "A", + Rect::new(0.0, 0.0, 15.0, 100.0), + ParaAttrs { composer: Some(composer), mojikumi: Some("MojikumiTable/Endpoint".into()), ..Default::default() }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Endpoint".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 18, + side_class: if leading { 23 } else { 22 }, + after: !leading, + minimum: 0.0, + desired: 1.0, + maximum: 1.0, + priority: 1, + does_not_float: true, + }], + }); + let result = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&result)[0]; + assert!(line.end_x <= 15.0, "{composer:?}, leading={leading}: {}", line.end_x); + assert!(line.glyphs[0].x.abs() < 0.001, "Discrete leading gap must reach its endpoint"); + } + } +} From d9f24b6ad59fb0550fa2e5f8d20f4596890fbc8c Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 20:09:29 +0800 Subject: [PATCH 07/12] CJK-MOJI6: respect glyph clusters and regional punctuation metrics --- ROADMAP.md | 1 + crates/compose/src/mojikumi.rs | 86 ++++++++++++++++++++++------------ crates/compose/src/shape.rs | 2 + crates/compose/src/tests.rs | 30 ++++++++++++ crates/doc/src/mojikumi.rs | 51 ++++++++++++++++++-- 5 files changed, 136 insertions(+), 34 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1897b38e2..4946a9f1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # DesignCraft roadmap +- CJK-MOJI6: apply spacing at shaped-cluster boundaries and distinguish centered Traditional Chinese punctuation from Simplified Chinese side bearings. - CJK-MOJI5: share equal-priority boundary spacing and honor discrete compression endpoints, including leading-only ragged-line fitting. - CJK-MOJI4: resolve all public Mojikumi preset names and their distinct paragraph/line-edge policies; correct consecutive punctuation defaults and small-kana classes. diff --git a/crates/compose/src/mojikumi.rs b/crates/compose/src/mojikumi.rs index 23dde00e3..c0d92779e 100644 --- a/crates/compose/src/mojikumi.rs +++ b/crates/compose/src/mojikumi.rs @@ -8,6 +8,8 @@ use designcraft_doc::{ #[derive(Clone, Copy, Debug, Default)] pub struct Metrics { pub active: bool, + /// Shaped body width before tracking and manual character spacing. + pub body: f64, /// Desired internal gap attached to this glyph's advance. pub gap: f64, pub stretch: f64, @@ -38,42 +40,68 @@ fn em(g: &Glyph) -> f64 { pub fn apply(glyphs: &mut [Glyph], styles: &Styles, name: &str) { let Ok(Some(table)) = Rules::resolve(styles, name) else { return }; - // Remove punctuation's built-in blank half-body. The table then contributes - // the desired spacing once per boundary, rather than adding it twice. - for g in glyphs.iter_mut().filter(|g| eligible(g)) { - g.moji.active = true; - let class = rules::class(g.rendered_char); - let unit = em(g); - if g.adv >= unit * 0.8 { - let before = if g.moji.explicit_before { 0.0 } else { rules::leading(class) * unit }; - let after = if g.moji.explicit_after { 0.0 } else { rules::trailing(class) * unit }; - g.adv = (g.adv - before - after).max(0.0); - g.dx -= before; + // Boundaries belong to shaped clusters, not individual glyphs. In particular + // the advance carrying a following gap must come after all attached marks. + let mut clusters = Vec::new(); + let mut start = 0; + while start < glyphs.len() { + let byte = glyphs[start].byte; + let mut end = start + 1; + while end < glyphs.len() && glyphs[end].byte == byte { + end += 1; } + clusters.push((start, end)); + start = end; } - for i in 0..glyphs.len() { - let (left, right) = glyphs.split_at_mut(i + 1); - let Some(g) = left.last_mut() else { continue }; - if !g.moji.active { + for &(start, end) in &clusters { + let cluster = &mut glyphs[start..end]; + if !cluster.iter().any(eligible) { continue; } - let c = rules::class(g.rendered_char); - let unit = em(g); - let start = table.pair(if i == 0 { 23 } else { 22 }, c); - g.moji.start_aki = start; - g.moji.start = if g.moji.explicit_before { 0.0 } else { start.desired * unit }; - g.moji.end_aki = table.pair(c, 22); - let mut gap = Aki::default(); - if let Some(next) = right.first().filter(|n| n.moji.active && n.byte != g.byte) - && !g.moji.explicit_after - && !next.moji.explicit_before - { - gap = table.pair(c, rules::class(next.rendered_char)); + let class = rules::class(cluster[0].rendered_char); + let unit = em(&cluster[0]); + let body: f64 = cluster.iter().map(|g| g.moji.body).sum(); + for g in cluster.iter_mut() { + g.moji.active = true; } - let desired = (gap.desired * unit).max(-g.adv); + // Tracking must not make a proportional or half-width glyph appear to + // contain a removable half-em body. Shaping features already affect body. + if body >= unit * 0.8 { + let before = if cluster[0].moji.explicit_before { 0.0 } else { table.leading(class) * unit }; + let after = if cluster[0].moji.explicit_after { 0.0 } else { table.trailing(class) * unit }; + let trim = before + after; + if let Some(owner) = cluster.iter().position(|g| g.adv >= trim) { + cluster[owner].adv -= trim; + for (i, g) in cluster.iter_mut().enumerate() { + g.dx += if i > owner { trim - before } else { -before }; + } + } + } + let head = &mut cluster[0]; + head.moji.start_aki = table.pair(if start == 0 { 23 } else { 22 }, class); + head.moji.start = if head.moji.explicit_before { 0.0 } else { head.moji.start_aki.desired * unit }; + } + for (index, &(start, end)) in clusters.iter().enumerate() { + if !glyphs[start].moji.active { + continue; + } + let class = rules::class(glyphs[start].rendered_char); + let unit = em(&glyphs[start]); + let mut gap = Aki::default(); + if let Some(&(next, _)) = clusters.get(index + 1) + && glyphs[next].moji.active + && !glyphs[end - 1].moji.explicit_after + && !glyphs[next].moji.explicit_before + { + gap = table.pair(class, rules::class(glyphs[next].rendered_char)); + } + let width: f64 = glyphs[start..end].iter().map(|g| g.adv).sum(); + let g = &mut glyphs[end - 1]; + g.moji.end_aki = table.pair(class, 22); + let desired = (gap.desired * unit).max(-width); g.moji.gap = desired; g.moji.stretch = (gap.max * unit - desired).max(0.0); - g.moji.shrink = (desired - gap.min * unit).max(0.0).min(g.adv + desired); + g.moji.shrink = (desired - gap.min * unit).max(0.0).min(width + desired); g.moji.priority = gap.priority; g.moji.discrete = gap.discrete; g.moji.end = if g.moji.explicit_after { 0.0 } else { g.moji.end_aki.desired * unit } - desired; diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 9391f3a19..e1d46aa25 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -913,6 +913,7 @@ fn shape_segment( (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) }; + let body = adv; if ch == SOFT_HYPHEN { adv = 0.0; } else if last_in_cluster { @@ -950,6 +951,7 @@ fn shape_segment( break_after: None, cjk_hang: 0.0, moji: crate::mojikumi::Metrics { + body, explicit_before: p.leading_aki.is_some(), explicit_after: p.trailing_aki.is_some(), ..Default::default() diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e967940d2..53c9dbcce 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2293,3 +2293,33 @@ fn mojikumi_nonfloating_and_leading_only_compression_fit_ragged_lines() { } } } + +#[test] +fn mojikumi_places_custom_gap_after_combining_marks() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with("A\u{0323}\u{0301}1", Rect::new(0.0, 0.0, 200.0, 100.0), ParaAttrs::default()); + let natural = compose_story(&d, sid, &ComposeOptions::default()); + let old = &all_lines(&natural)[0].glyphs; + assert!(old.len() >= 3, "Exercise a cluster with a separate combining glyph"); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Clusters".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 18, + side_class: 25, + after: true, + minimum: 1.0, + desired: 1.0, + maximum: 1.0, + ..Default::default() + }], + }); + d.story_mut(sid).unwrap().paras[0].para.mojikumi = Some("MojikumiTable/Clusters".into()); + let result = compose_story(&d, sid, &ComposeOptions::default()); + let new = &all_lines(&result)[0].glyphs; + assert_eq!(new.len(), old.len()); + for (before, after) in old.iter().zip(new) { + let expected = if before.rendered_char == '1' { 12.0 } else { 0.0 }; + assert!((after.x - before.x - expected).abs() < 0.001, "Glyph {:?} moved incorrectly", before.rendered_char); + } +} diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index bff676c9f..eda698371 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -49,12 +49,30 @@ impl Preset { (1..=16).contains(&n).then_some(Self(n)) }) } + fn leading(self, c: i16) -> f64 { + if self.0 == 15 && matches!(c, 6 | 21 | 30 | 31) { + 0.25 + } else if self.0 == 16 && c == 32 { + 0.0 + } else { + leading(c) + } + } + fn trailing(self, c: i16) -> f64 { + if self.0 == 15 && matches!(c, 6 | 21 | 30 | 31) { + 0.25 + } else if self.0 == 16 && c == 32 { + 0.5 + } else { + trailing(c) + } + } fn boundary(self, left: i16, right: i16) -> Option { let n = self.0; let open = |c| matches!(c, 1 | 26 | 27); if left == 23 { if n >= 15 { - return Some(Aki::fixed(leading(right))); + return Some(Aki::fixed(self.leading(right))); } let indent = if matches!(n, 1 | 10 | 11 | 14) { 0.0 } else { 1.0 }; let opening = match n { @@ -63,13 +81,13 @@ impl Preset { 4 | 6 | 9 | 12 => 1.0, _ => 0.0, }; - return Some(Aki::fixed(if open(right) { opening } else { indent + leading(right) })); + return Some(Aki::fixed(if open(right) { opening } else { indent + self.leading(right) })); } if left == 22 { - return Some(Aki::fixed(if n >= 15 || matches!(n, 4 | 5 | 10) { leading(right) } else { 0.0 })); + return Some(Aki::fixed(if n >= 15 || matches!(n, 4 | 5 | 10) { self.leading(right) } else { 0.0 })); } if right == 22 { - let width = trailing(left); + let width = self.trailing(left); let full = n >= 15 || matches!(n, 4 | 5 | 10) || (matches!(n, 12..=14) && matches!(left, 6 | 31)); let discrete = n == 6 || (matches!(n, 7 | 8 | 11) && matches!(left, 2 | 6 | 21 | 28..=31)); return Some(if full { @@ -114,6 +132,14 @@ impl<'a> Rules<'a> { Ok(Some(Self { preset, rows })) } + /// Blank body portions for the preset's regional punctuation convention. + pub fn leading(&self, class: i16) -> f64 { + self.preset.leading(class) + } + pub fn trailing(&self, class: i16) -> f64 { + self.preset.trailing(class) + } + pub fn pair(&self, left: i16, right: i16) -> Aki { if let Some(row) = self .rows @@ -139,7 +165,7 @@ impl<'a> Rules<'a> { { return Aki::fixed(0.0); } - let punctuation = trailing(left).max(leading(right)); + let punctuation = self.trailing(left).max(self.leading(right)); if punctuation > 0.0 { return Aki { min: 0.0, desired: punctuation, max: punctuation, priority: 1, discrete: false }; } @@ -239,6 +265,21 @@ mod tests { use super::*; use crate::cjk::MojikumiTable; + #[test] + fn regional_punctuation_retains_the_correct_side_bearings() { + let styles = Styles::default(); + let traditional = Rules::resolve(&styles, "TradChineseDefault").unwrap().unwrap(); + let simplified = Rules::resolve(&styles, "SimpChineseDefault").unwrap().unwrap(); + assert_eq!(traditional.leading(class('。')), 0.25); + assert_eq!(traditional.trailing(class('。')), 0.25); + assert_eq!(simplified.leading(class('。')), 0.0); + assert_eq!(simplified.trailing(class('。')), 0.5); + assert_eq!(traditional.pair(12, class('。')).desired, 0.25); + assert_eq!(simplified.pair(12, class('。')).desired, 0.0); + assert_eq!(simplified.leading(class(':')), 0.0); + assert_eq!(simplified.trailing(class(':')), 0.5); + } + #[test] fn all_public_presets_have_distinct_documented_edges() { let styles = Styles::default(); From bc43965a4587dfe1dba24e7e2d795b9ed4933671 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 20:14:16 +0800 Subject: [PATCH 08/12] CJK-MOJI7: validate spacing data and inspect nested typography rules --- ROADMAP.md | 1 + crates/doc/src/lib.rs | 4 +- crates/doc/src/mojikumi.rs | 15 +++++--- crates/engine/src/cmd/fonts.rs | 2 +- crates/engine/src/cmd/preflight.rs | 27 +++++++++++++- crates/idml/src/import.rs | 59 +++++++++++++++++++++--------- crates/idml/src/tests.rs | 32 ++++++++++++++++ 7 files changed, 114 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4946a9f1a..72d2186ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # DesignCraft roadmap +- CJK-MOJI7: reject malformed Mojikumi interchange values and check nested note/cell paragraph rules in preflight. - CJK-MOJI6: apply spacing at shaped-cluster boundaries and distinguish centered Traditional Chinese punctuation from Simplified Chinese side bearings. - CJK-MOJI5: share equal-priority boundary spacing and honor discrete compression endpoints, including leading-only ragged-line fitting. - CJK-MOJI4: resolve all public Mojikumi preset names and their distinct paragraph/line-edge policies; correct consecutive punctuation defaults and small-kana classes. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 32e0e90cf..33e0c7a9a 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -706,8 +706,8 @@ impl Document { } } for t in &self.styles.mojikumi_tables { - if t.overrides.iter().any(|r| ![r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite())) { - return Err(DocError::Invalid(format!("non-finite mojikumi spacing: {}", t.name))); + if t.overrides.iter().any(|r| !mojikumi::valid_range(r)) { + return Err(DocError::Invalid(format!("invalid mojikumi spacing: {}", t.name))); } } let mut ids = std::collections::HashSet::new(); diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index eda698371..14e4433c3 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -121,11 +121,7 @@ impl<'a> Rules<'a> { if !valid_class(r.target_class) || !valid_class(r.side_class) { return Err(format!("unsupported character classes {}/{}", r.target_class, r.side_class)); } - if ![r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite() && (-1.0..=100.0).contains(v)) - || r.minimum > r.desired - || r.desired > r.maximum - || !(0..=9).contains(&r.priority) - { + if !valid_range(r) { return Err("invalid spacing range or priority".into()); } } @@ -181,6 +177,15 @@ impl<'a> Rules<'a> { } } +/// Structural validation shared by native document loading and interchange. +/// Unknown integer classes remain preservable and are diagnosed by the resolver. +pub fn valid_range(r: &MojikumiAki) -> bool { + [r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite() && (-1.0..=100.0).contains(v)) + && r.minimum <= r.desired + && r.desired <= r.maximum + && (0..=9).contains(&r.priority) +} + pub fn valid_class(c: i16) -> bool { matches!(c, 1..=12 | 18 | 21..=33) } diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 83acd685b..2184db7c0 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec { } /// Every story's text, including table cells and footnotes. -fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) { +pub(super) fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) { let mut pending: Vec<&Story> = d.stories.values().map(AsRef::as_ref).collect(); while let Some(st) = pending.pop() { f(st); diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 22a60fa2c..5a69ff1bf 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -138,6 +138,9 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { page: location.and_then(|(_, page)| page), }); } + } + // Nested note and cell text uses paragraph rules too. + super::fonts::for_each_story(d, &mut |story| { let ranges = story.para_ranges(); for (pi, pf) in story.paras.iter().enumerate() { let (para, base) = d.styles.resolve_para(pf); @@ -183,7 +186,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { } } } - } + }); // Use the same effective character-to-font mapping as Find Font, including // anonymous note/cell stories. Unused composite entries are not missing text. for font in super::fonts::used_fonts(d) { @@ -403,3 +406,25 @@ mod generated_glyph_tests { assert_eq!(issue["page"], 0); } } + +#[cfg(test)] +mod mojikumi_nested_tests { + use super::*; + use std::sync::Arc; + #[test] + fn unknown_table_in_a_note_inside_a_cell_is_reported() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect":[20,20,300,400],"content":"text","text":"Body"})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let sid = *d.stories.keys().next().unwrap(); + let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 100.0); + let cell = &mut table.cells[0].text; + let mut pf = designcraft_doc::ParaFormat::default(); + pf.para.mojikumi = Some("MojikumiTable/Unrecognized".into()); + cell.insert_note(0, "Note", pf); + d.story_mut(sid).unwrap().insert_table(0, table); + let issues = check(&s, 150.0); + assert_eq!(issues.iter().filter(|i| i.kind == "unsupportedTypography" && i.message.contains("Unrecognized")).count(), 1); + } +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index c45775d98..932062724 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -143,6 +143,43 @@ fn lab_to_color(l: f32, a: f32, b: f32) -> Color { Color::rgb(rgb[0], rgb[1], rgb[2]) } +fn mojikumi_row(e: &El) -> Result { + let number = |key: &str, required: bool| -> Result { + let Some(raw) = e.prop(key) else { + return if required { Err(IdmlError::Invalid(format!("Mojikumi row is missing {key}"))) } else { Ok(0.0) }; + }; + raw.trim().parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| IdmlError::Invalid(format!("Mojikumi row has invalid {key}"))) + }; + let integer = |key: &str, required: bool| -> Result { + let v = number(key, required)?; + if v.fract() != 0.0 || v < f64::from(i16::MIN) || v > f64::from(i16::MAX) { + return Err(IdmlError::Invalid(format!("Mojikumi {key} must be an in-range integer"))); + } + Ok(v as i16) + }; + let boolean = |key: &str| -> Result { + match e.prop(key).as_deref().map(str::trim) { + None | Some("false") => Ok(false), + Some("true") => Ok(true), + _ => Err(IdmlError::Invalid(format!("Mojikumi row has invalid {key}"))), + } + }; + let row = designcraft_doc::cjk::MojikumiAki { + target_class: integer("TargetMojikumiClass", true)?, + side_class: integer("SideMojikumiClass", true)?, + after: boolean("SideIsAfterTarget")?, + minimum: number("Minimum", false)?, + desired: number("Desired", false)?, + maximum: number("Maximum", false)?, + priority: integer("CompressionPriority", false)?, + does_not_float: boolean("AkiDoesNotFloat")?, + }; + if !designcraft_doc::mojikumi::valid_range(&row) { + return Err(IdmlError::Invalid("Mojikumi row has an invalid spacing range or priority".into())); + } + Ok(row) +} + fn nums(s: &str) -> Vec { s.split_whitespace().filter_map(|v| v.parse().ok()).collect() } @@ -316,23 +353,11 @@ impl<'r> Importer<'r> { if let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) { self.mojikumi_names.insert(id.into(), format!("MojikumiTable/{}", unescape_id(name))); } - let overrides = e - .prop_el("OverrideMojikumiAkiList") - .map(|l| { - l.find_all("OverrideMojikumiAkiType") - .map(|r| designcraft_doc::cjk::MojikumiAki { - target_class: r.num("TargetMojikumiClass").unwrap_or(0.0) as i16, - side_class: r.num("SideMojikumiClass").unwrap_or(0.0) as i16, - after: r.boolean("SideIsAfterTarget").unwrap_or(false), - minimum: r.num("Minimum").unwrap_or(0.0), - desired: r.num("Desired").unwrap_or(0.0), - maximum: r.num("Maximum").unwrap_or(0.0), - priority: r.num("CompressionPriority").unwrap_or(0.0) as i16, - does_not_float: r.boolean("AkiDoesNotFloat").unwrap_or(false), - }) - .collect() - }) - .unwrap_or_default(); + let overrides = if let Some(list) = e.prop_el("OverrideMojikumiAkiList") { + list.find_all("OverrideMojikumiAkiType").map(mojikumi_row).collect::>>()? + } else { + Vec::new() + }; self.styles.mojikumi_tables.push(designcraft_doc::cjk::MojikumiTable { name: unescape_id(e.get("Name").unwrap_or("")), based_on: e.get("BasedOnMojikumiSet").unwrap_or("").into(), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 651582ac4..79f242483 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -936,3 +936,35 @@ fn composite_references_resolve_by_identity_and_export_as_objects() { let back = import_idml(&output).unwrap(); assert_eq!(back.styles.composite_fonts, d.styles.composite_fonts); } + +#[test] +fn malformed_mojikumi_rows_are_not_silently_coerced() { + let good = r#""#; + for (key, value) in [ + ("TargetMojikumiClass", "12.9"), + ("SideMojikumiClass", "65536"), + ("Minimum", "NaN"), + ("Desired", "oops"), + ("Minimum", "0.4"), + ("Maximum", "0.1"), + ("CompressionPriority", "10"), + ("AkiDoesNotFloat", "maybe"), + ] { + let mut row = crate::xml::parse(good.as_bytes()).unwrap(); + row.set(key, value); + let mut xml = String::new(); + row.write(&mut xml, 0); + let map = DESIGNMAP.replace("", &format!("{xml}")); + let bytes = zip_files(&[ + ("designmap.xml", &map), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ]); + let err = import_idml(&bytes).unwrap_err().to_string(); + assert!(err.contains("Mojikumi"), "{key}={value}: {err}"); + } +} From 1e32767cef3c73e289c70e33219c39708f333027 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 20:18:30 +0800 Subject: [PATCH 09/12] CJK-MOJI8: preserve narrow punctuation and manual body compression --- ROADMAP.md | 1 + crates/compose/src/mojikumi.rs | 21 ++++++++++++++++----- crates/compose/src/shape.rs | 2 ++ crates/compose/src/tests.rs | 19 +++++++++++++++++++ crates/doc/src/mojikumi.rs | 33 +++++++++++++++++++++++++++++---- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 72d2186ad..27d2e8928 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # DesignCraft roadmap +- CJK-MOJI8: preserve shaped narrow punctuation and manual Tsume instead of restoring nonexistent full-width blank space. - CJK-MOJI7: reject malformed Mojikumi interchange values and check nested note/cell paragraph rules in preflight. - CJK-MOJI6: apply spacing at shaped-cluster boundaries and distinguish centered Traditional Chinese punctuation from Simplified Chinese side bearings. - CJK-MOJI5: share equal-priority boundary spacing and honor discrete compression endpoints, including leading-only ragged-line fitting. diff --git a/crates/compose/src/mojikumi.rs b/crates/compose/src/mojikumi.rs index c0d92779e..0c44e546e 100644 --- a/crates/compose/src/mojikumi.rs +++ b/crates/compose/src/mojikumi.rs @@ -10,6 +10,10 @@ pub struct Metrics { pub active: bool, /// Shaped body width before tracking and manual character spacing. pub body: f64, + pub tsume_before: f64, + pub tsume_after: f64, + pub blank_before: f64, + pub blank_after: f64, /// Desired internal gap attached to this glyph's advance. pub gap: f64, pub stretch: f64, @@ -67,10 +71,12 @@ pub fn apply(glyphs: &mut [Glyph], styles: &Styles, name: &str) { // Tracking must not make a proportional or half-width glyph appear to // contain a removable half-em body. Shaping features already affect body. if body >= unit * 0.8 { - let before = if cluster[0].moji.explicit_before { 0.0 } else { table.leading(class) * unit }; - let after = if cluster[0].moji.explicit_after { 0.0 } else { table.trailing(class) * unit }; + let before = if cluster[0].moji.explicit_before { 0.0 } else { (table.leading(class) * unit - cluster[0].moji.tsume_before).max(0.0) }; + let after = if cluster[0].moji.explicit_after { 0.0 } else { (table.trailing(class) * unit - cluster[0].moji.tsume_after).max(0.0) }; let trim = before + after; if let Some(owner) = cluster.iter().position(|g| g.adv >= trim) { + cluster[0].moji.blank_before = before / unit.max(1e-9); + cluster[end - start - 1].moji.blank_after = after / unit.max(1e-9); cluster[owner].adv -= trim; for (i, g) in cluster.iter_mut().enumerate() { g.dx += if i > owner { trim - before } else { -before }; @@ -78,7 +84,7 @@ pub fn apply(glyphs: &mut [Glyph], styles: &Styles, name: &str) { } } let head = &mut cluster[0]; - head.moji.start_aki = table.pair(if start == 0 { 23 } else { 22 }, class); + head.moji.start_aki = table.pair_with_bodies(if start == 0 { 23 } else { 22 }, class, 0.0, head.moji.blank_before); head.moji.start = if head.moji.explicit_before { 0.0 } else { head.moji.start_aki.desired * unit }; } for (index, &(start, end)) in clusters.iter().enumerate() { @@ -93,11 +99,16 @@ pub fn apply(glyphs: &mut [Glyph], styles: &Styles, name: &str) { && !glyphs[end - 1].moji.explicit_after && !glyphs[next].moji.explicit_before { - gap = table.pair(class, rules::class(glyphs[next].rendered_char)); + gap = table.pair_with_bodies( + class, + rules::class(glyphs[next].rendered_char), + glyphs[end - 1].moji.blank_after, + glyphs[next].moji.blank_before, + ); } let width: f64 = glyphs[start..end].iter().map(|g| g.adv).sum(); let g = &mut glyphs[end - 1]; - g.moji.end_aki = table.pair(class, 22); + g.moji.end_aki = table.pair_with_bodies(class, 22, g.moji.blank_after, 0.0); let desired = (gap.desired * unit).max(-width); g.moji.gap = desired; g.moji.stretch = (gap.max * unit - desired).max(0.0); diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index e1d46aa25..bbf91cbb8 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -477,6 +477,8 @@ fn shape_run( let tsume = p.tsume.clamp(0.0, 1.0); let before = p.leading_aki.unwrap_or(0.0).clamp(0.0, 100.0) * em; let after = p.trailing_aki.unwrap_or(0.0).clamp(0.0, 100.0) * em; + g.moji.tsume_before = left * tsume; + g.moji.tsume_after = right * tsume; g.dx += before - left * tsume; g.adv += before + after - (left + right) * tsume; } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 53c9dbcce..69e74f1a7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2323,3 +2323,22 @@ fn mojikumi_places_custom_gap_after_combining_marks() { assert!((after.x - before.x - expected).abs() < 0.001, "Glyph {:?} moved incorrectly", before.rendered_char); } } + +#[test] +fn mojikumi_does_not_restore_fullwidth_blanks_to_narrow_tracked_punctuation() { + let family = "Mojikumi Narrow Body"; + let font = designcraft_fonts::testing::font_mapping(family, &[('(', 'i'), ('A', 'A')]).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + let (mut d, sid, _) = doc_with("(A", Rect::new(0.0, 0.0, 200.0, 100.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0.."(A".len(), |f| { + f.over.font_family = Some(family.into()); + f.over.tracking = Some(1000.0); + }); + let natural = compose_story(&d, sid, &ComposeOptions::default()); + d.story_mut(sid).unwrap().paras[0].para.mojikumi = Some("SimpChineseDefault".into()); + let result = compose_story(&d, sid, &ComposeOptions::default()); + for (a, b) in all_lines(&natural)[0].glyphs.iter().zip(&all_lines(&result)[0].glyphs) { + assert!((a.x - b.x).abs() < 0.001); + assert!((a.adv - b.adv).abs() < 0.001); + } +} diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index 14e4433c3..aaedd355a 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -136,13 +136,35 @@ impl<'a> Rules<'a> { self.preset.trailing(class) } - pub fn pair(&self, left: i16, right: i16) -> Aki { - if let Some(row) = self - .rows + fn override_pair(&self, left: i16, right: i16) -> Option<&MojikumiAki> { + self.rows .iter() .rev() .find(|r| if r.after { r.target_class == left && r.side_class == right } else { r.target_class == right && r.side_class == left }) - { + } + + /// Preset spacing restores only blank removed from shaped punctuation bodies. + /// Explicit custom rows retain their requested dimensions. + pub fn pair_with_bodies(&self, left: i16, right: i16, after_left: f64, before_right: f64) -> Aki { + let mut aki = self.pair(left, right); + if self.override_pair(left, right).is_none() { + let missing = if matches!(left, 22 | 23) { + self.leading(right) - before_right + } else if right == 22 { + self.trailing(left) - after_left + } else { + self.trailing(left).max(self.leading(right)) - after_left.max(before_right) + } + .max(0.0); + aki.min = (aki.min - missing).max(0.0); + aki.desired = (aki.desired - missing).max(0.0); + aki.max = (aki.max - missing).max(0.0); + } + aki + } + + pub fn pair(&self, left: i16, right: i16) -> Aki { + if let Some(row) = self.override_pair(left, right) { return Aki { min: row.minimum, desired: row.desired, @@ -283,6 +305,8 @@ mod tests { assert_eq!(simplified.pair(12, class('。')).desired, 0.0); assert_eq!(simplified.leading(class(':')), 0.0); assert_eq!(simplified.trailing(class(':')), 0.5); + assert!((simplified.pair_with_bodies(6, 12, 0.1, 0.0).desired - 0.1).abs() < 1e-9); + assert_eq!(simplified.pair_with_bodies(22, 27, 0.0, 0.0).desired, 0.0); } #[test] @@ -328,6 +352,7 @@ mod tests { }); let rule = Rules::resolve(&styles, "MojikumiTable/Pair").unwrap().unwrap(); assert_eq!(rule.pair(18, 12).desired, 0.2); + assert_eq!(rule.pair_with_bodies(18, 12, 0.0, 0.0).desired, 0.2); assert_eq!(rule.pair(12, 18).desired, 0.25); assert!(Rules::resolve(&styles, "Unknown").is_err()); assert!(Rules::resolve(&styles, "Nothing").unwrap().is_none()); From 20ce4f5ecdb192709231251f896571bdc07bc65d Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 20:24:59 +0800 Subject: [PATCH 10/12] CJK-MOJI9: preserve CID preferences and report unsupported glyph classification --- ROADMAP.md | 1 + crates/doc/src/lib.rs | 5 ++++ crates/engine/src/cmd/preflight.rs | 44 ++++++++++++++++++++++++++++++ crates/engine/src/cmd/prefs.rs | 3 +- crates/fonts/src/fontdb.rs | 35 ++++++++++++++++++++++++ crates/idml/src/export.rs | 1 + crates/idml/src/import.rs | 3 ++ crates/idml/src/tests.rs | 8 ++++++ docs/mojikumi.md | 29 +++++++++++++++++--- 9 files changed, 124 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 27d2e8928..4f349d892 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # DesignCraft roadmap +- CJK-MOJI9: retain the CID Mojikumi compatibility preference and diagnose actual Adobe-Japan1 font use; exact CID class mapping remains unsupported. - CJK-MOJI8: preserve shaped narrow punctuation and manual Tsume instead of restoring nonexistent full-width blank space. - CJK-MOJI7: reject malformed Mojikumi interchange values and check nested note/cell paragraph rules in preflight. - CJK-MOJI6: apply spacing at shaped-cluster boundaries and distinguish centered Traditional Chinese punctuation from Simplified Chinese side bearings. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 33e0c7a9a..ca6dd5d23 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -352,6 +352,10 @@ pub struct DocSettings { /// Documents saved before the setting existed read as on, the way they were drawn. #[serde(default = "yes")] pub glyph_fallback: bool, + /// Preserve IDML's CID-based Mojikumi request. Unicode classification is + /// currently used; preflight reports actual Adobe-Japan1 font use. + #[serde(default)] + pub use_cid_mojikumi: bool, } impl Default for DocSettings { @@ -384,6 +388,7 @@ impl Default for DocSettings { overprint_black: true, track_changes: false, glyph_fallback: false, + use_cid_mojikumi: false, } } } diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 5a69ff1bf..5d0cfdb87 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -110,6 +110,19 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des None } +fn rendered_faces(cs: &ComposedStory, faces: &mut Vec) { + let mut pending: Vec<_> = cs.frames.iter().collect(); + while let Some(frame) = pending.pop() { + for g in frame.lines.iter().flat_map(|line| &line.glyphs).filter(|g| g.visible) { + if !faces.contains(&g.face) { + faces.push(g.face); + } + } + pending.extend(frame.notes.iter().flat_map(|n| &n.text.frames)); + pending.extend(frame.tables.iter().flat_map(|t| t.cells.iter().flat_map(|c| &c.text.frames))); + } +} + pub fn check(s: &Session, min_ppi: f64) -> Vec { let Some(st) = s.active() else { return vec![] }; let d = &st.doc; @@ -117,10 +130,15 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { // Stories: overset and fonts. let mut missing_fonts: Vec = Vec::new(); let mut unsupported_typography = std::collections::BTreeSet::new(); + let mut cid_faces = Vec::new(); + let mut uses_mojikumi = false; let mut missing: Vec<(FaceRef, Vec, designcraft_doc::ItemId)> = Vec::new(); for story in d.stories.values() { let cs = s.cache.get(d, story.id, None); missing_glyphs(&cs, None, 0, &mut missing); + if d.settings.use_cid_mojikumi { + rendered_faces(&cs, &mut cid_faces); + } if cs.is_overset() { let last = story.frames.last().copied(); let location = last.and_then(|f| overset_location(s, f)); @@ -144,6 +162,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { let ranges = story.para_ranges(); for (pi, pf) in story.paras.iter().enumerate() { let (para, base) = d.styles.resolve_para(pf); + uses_mojikumi |= !matches!(para.mojikumi.as_str(), "" | "Nothing" | "None"); let Some(range) = ranges.get(pi) else { continue }; let has_rtl = story.text.get(range.clone()).is_some_and(|text| text.chars().any(designcraft_fonts::is_rtl)); if has_rtl { @@ -207,6 +226,14 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { for f in missing_fonts { out.push(Issue { severity: "error", kind: "missingFont", message: format!("Missing font: {f}"), item: None, page: None }); } + if uses_mojikumi { + for face in cid_faces.iter().filter(|face| face.uses_japan1_cids()) { + unsupported_typography.insert(format!( + "CID-based Mojikumi requested for {} {}; Unicode classes are used because CID class mapping is not implemented", + face.family, face.style + )); + } + } for message in unsupported_typography { out.push(Issue { severity: "warning", kind: "unsupportedTypography", message, item: None, page: None }); } @@ -428,3 +455,20 @@ mod mojikumi_nested_tests { assert_eq!(issues.iter().filter(|i| i.kind == "unsupportedTypography" && i.message.contains("Unrecognized")).count(), 1); } } + +#[cfg(test)] +mod cid_preference_tests { + use super::*; + #[test] + fn cid_request_is_undoable_and_does_not_warn_for_unicode_only_fonts() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect":[0,0,300,200], "content":"text", "text":"ABC"})).unwrap(); + s.execute("text.select", &json!({"story":r["story"],"anchor":0,"focus":3})).unwrap(); + s.execute("type.para", &json!({"attrs":{"mojikumi":"SimpChineseDefault"}})).unwrap(); + assert_eq!(s.execute("document.preferences", &json!({"useCidMojikumi":true})).unwrap()["useCidMojikumi"], true); + assert!(!check(&s, 150.0).iter().any(|i| i.message.contains("CID-based"))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("document.preferences", &json!({})).unwrap()["useCidMojikumi"], false); + } +} diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 68a5cf152..9fc0fb5de 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -21,6 +21,7 @@ const DOC_KEYS: &[&str] = &[ "advancedType", "overprintBlack", "glyphFallback", + "useCidMojikumi", ]; pub fn specs() -> Vec { @@ -62,7 +63,7 @@ pub fn specs() -> Vec { "Document Preferences", [], None, - "{horizontalUnits?, verticalUnits?: points|picas|inches|millimeters|…, keyboardIncrement? (pt), baselineGrid?: {start, increment, relativeTo, color, viewThreshold}, grid?: {horizontal, vertical, subdivisions, color, inBack}, pasteboard?: [h, v], marginColor?, columnColor?, bleedColor?, slugColor?: [r, g, b], advancedType?: {superscriptSize, superscriptPosition, subscriptSize, subscriptPosition} (%), overprintBlack?, glyphFallback? (draw characters the font lacks from fallback fonts; off: as the font's missing-glyph box)} → those settings", + "{horizontalUnits?, verticalUnits?: points|picas|inches|millimeters|…, keyboardIncrement? (pt), baselineGrid?: {start, increment, relativeTo, color, viewThreshold}, grid?: {horizontal, vertical, subdivisions, color, inBack}, pasteboard?: [h, v], marginColor?, columnColor?, bleedColor?, slugColor?: [r, g, b], advancedType?: {superscriptSize, superscriptPosition, subscriptSize, subscriptPosition} (%), overprintBlack?, useCidMojikumi? (preserve CID classification request; unsupported font collections are diagnosed), glyphFallback? (draw characters the font lacks from fallback fonts; off: as the font's missing-glyph box)} → those settings", has_doc, |s, p| { let cur = serde_json::to_value(&s.doc()?.doc.settings).map_err(|e| bad("document.preferences", e.to_string()))?; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 2bc2b00ed..8c979e15c 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -181,6 +181,13 @@ impl std::fmt::Debug for FontFace { } impl FontFace { + /// Adobe-Japan1 CFF fonts are the collection for CID-based Mojikumi. + /// Other CID collections still use Unicode classes under Adobe's policy. + pub fn uses_japan1_cids(&self) -> bool { + use skrifa::raw::TableProvider; + self.skrifa().and_then(|f| f.cff().ok()).is_some_and(cff_uses_japan1_cids) + } + pub fn data(&self) -> &[u8] { match &self.bytes { FontBytes::Static(b) => b, @@ -1567,3 +1574,31 @@ mod tests_docfonts; #[cfg(test)] #[path = "tests_vertical.rs"] mod tests_vertical; + +fn cff_uses_japan1_cids(cff: skrifa::raw::tables::cff::Cff<'_>) -> bool { + use skrifa::raw::ps::cff::dict; + let Some(top) = cff.top_dicts().get(0) else { return false }; + dict::entries(top, None).any(|entry| match entry { + Ok(dict::Entry::Ros { registry, ordering, .. }) => cff.string(registry) == Some(b"Adobe") && cff.string(ordering) == Some(b"Japan1"), + _ => false, + }) +} + +#[cfg(test)] +mod mojikumi_cid_tests { + use super::*; + use skrifa::raw::{FontData, FontRead, tables::cff::Cff}; + #[test] + fn recognizes_the_collection_in_cff_metadata_without_reading_outlines() { + // Minimal synthetic CFF indices: one name, a ROS dict, two strings, + // and one return-only subroutine. SID 391/392 reference the two strings. + let mut data = vec![1, 0, 4, 4, 0, 1, 1, 1, 2, b'T', 0, 1, 1, 1, 8, 248, 27, 248, 28, 139, 12, 30, 0, 2, 1, 1, 6, 12]; + data.extend_from_slice(b"AdobeJapan1"); + data.extend_from_slice(&[0, 1, 1, 1, 2, 11]); + assert!(cff_uses_japan1_cids(Cff::read(FontData::new(&data)).unwrap())); + let end = data.len() - 6; + data[end - 6..end].copy_from_slice(b"CNS1-X"); + assert!(!cff_uses_japan1_cids(Cff::read(FontData::new(&data)).unwrap())); + assert!(!FontDb::global().face(crate::DEFAULT_FAMILY, "Regular").uses_japan1_cids()); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 8dd965473..faa124ef8 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -799,6 +799,7 @@ impl<'a> Ex<'a> { let a = &s.advanced_type; root.push( El::new("TextPreference") + .attr("UseCidMojikumi", s.use_cid_mojikumi) .attr("SuperscriptSize", num(a.superscript_size)) .attr("SuperscriptPosition", num(a.superscript_position)) .attr("SubscriptSize", num(a.subscript_size)) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 932062724..aaef23dac 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -273,6 +273,9 @@ impl<'r> Importer<'r> { } } "TextPreference" => { + if let Some(v) = e.boolean("UseCidMojikumi") { + self.settings.use_cid_mojikumi = v; + } let a = &mut self.settings.advanced_type; for (k, v) in [ ("SuperscriptSize", &mut a.superscript_size), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 79f242483..7aa9fc3c1 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -968,3 +968,11 @@ fn malformed_mojikumi_rows_are_not_silently_coerced() { assert!(err.contains("Mojikumi"), "{key}={value}: {err}"); } } + +#[test] +fn cid_mojikumi_preference_survives_interchange() { + let mut d = import_idml(&fixture_with_story(STORY)).unwrap(); + d.settings.use_cid_mojikumi = true; + let restored = import_idml(&export_idml(&d)).unwrap(); + assert!(restored.settings.use_cid_mojikumi); +} diff --git a/docs/mojikumi.md b/docs/mojikumi.md index c9390d58d..d3baa075c 100644 --- a/docs/mojikumi.md +++ b/docs/mojikumi.md @@ -26,8 +26,29 @@ Regression tests cover preset boundary differences, custom override precedence, IDML reference round trips, and unsupported names. The implementation must not be represented as a byte-for-byte reproduction of Adobe's full preset matrices. -## Audit work +## Composition and validation -Further audit areas are regional punctuation metrics, shaped cluster boundaries, -equal-priority allocation, discrete line fitting, input validation, and diagnostics -for compatibility modes whose glyph classification cannot yet be reproduced. +Spacing is attached to complete shaped clusters. Regional punctuation side +bearings, shaped narrow forms, tracking, manual Tsume and explicit character aki +are handled before line fitting. Continuous gaps of equal priority share one +allocation pool; non-floating gaps reach discrete endpoints. Leading-only +compression is included for ragged lines. Custom rows retain their dimensions. + +Import rejects malformed integers, non-finite amounts, inverted ranges and invalid +priorities. Native document validation uses the same range policy. Unknown integer +classes remain preservable and receive diagnostics, including in nested notes and +cells. The bounded supported spacing range is -1 to 100 em. + +## Compatibility limits + +[Adobe's TextPreference API](https://developer.adobe.com/indesign/uxp/omv/t/TextPreference/) +defines CID-based classification. The IDML `UseCidMojikumi` preference now survives +round trips and is accessible through `document.preferences`. The engine currently +uses Unicode classes. Preflight reports the incompatibility when the document +requests CID mode and actually renders Adobe-Japan1 CFF fonts with Mojikumi in use; +it does not flag ordinary Unicode fonts merely because the preference is enabled. + +Exact Adobe CID-to-Mojikumi class mapping, complete proprietary preset pair matrices, +and legacy pre-CS2 vertical-scaling compatibility have not been reproduced or +certified. Public CMap resources map character codes to CIDs; they are not a +CID-to-Mojikumi-class oracle. No claim of complete Adobe output parity is made. From 56febef6ab311c2aad846043d23ecc98cf05ccf0 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 20:32:53 +0800 Subject: [PATCH 11/12] CJK-REVERT1: revert CID preference and diagnostic expansion Revert 20ce4f5 because the compatibility extension does not implement CID-based layout and is not required for the verified spacing repairs. --- ROADMAP.md | 1 - crates/doc/src/lib.rs | 5 ---- crates/engine/src/cmd/preflight.rs | 44 ------------------------------ crates/engine/src/cmd/prefs.rs | 3 +- crates/fonts/src/fontdb.rs | 35 ------------------------ crates/idml/src/export.rs | 1 - crates/idml/src/import.rs | 3 -- crates/idml/src/tests.rs | 8 ------ docs/mojikumi.md | 29 +++----------------- 9 files changed, 5 insertions(+), 124 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4f349d892..27d2e8928 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,5 @@ # DesignCraft roadmap -- CJK-MOJI9: retain the CID Mojikumi compatibility preference and diagnose actual Adobe-Japan1 font use; exact CID class mapping remains unsupported. - CJK-MOJI8: preserve shaped narrow punctuation and manual Tsume instead of restoring nonexistent full-width blank space. - CJK-MOJI7: reject malformed Mojikumi interchange values and check nested note/cell paragraph rules in preflight. - CJK-MOJI6: apply spacing at shaped-cluster boundaries and distinguish centered Traditional Chinese punctuation from Simplified Chinese side bearings. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ca6dd5d23..33e0c7a9a 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -352,10 +352,6 @@ pub struct DocSettings { /// Documents saved before the setting existed read as on, the way they were drawn. #[serde(default = "yes")] pub glyph_fallback: bool, - /// Preserve IDML's CID-based Mojikumi request. Unicode classification is - /// currently used; preflight reports actual Adobe-Japan1 font use. - #[serde(default)] - pub use_cid_mojikumi: bool, } impl Default for DocSettings { @@ -388,7 +384,6 @@ impl Default for DocSettings { overprint_black: true, track_changes: false, glyph_fallback: false, - use_cid_mojikumi: false, } } } diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 5d0cfdb87..5a69ff1bf 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -110,19 +110,6 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des None } -fn rendered_faces(cs: &ComposedStory, faces: &mut Vec) { - let mut pending: Vec<_> = cs.frames.iter().collect(); - while let Some(frame) = pending.pop() { - for g in frame.lines.iter().flat_map(|line| &line.glyphs).filter(|g| g.visible) { - if !faces.contains(&g.face) { - faces.push(g.face); - } - } - pending.extend(frame.notes.iter().flat_map(|n| &n.text.frames)); - pending.extend(frame.tables.iter().flat_map(|t| t.cells.iter().flat_map(|c| &c.text.frames))); - } -} - pub fn check(s: &Session, min_ppi: f64) -> Vec { let Some(st) = s.active() else { return vec![] }; let d = &st.doc; @@ -130,15 +117,10 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { // Stories: overset and fonts. let mut missing_fonts: Vec = Vec::new(); let mut unsupported_typography = std::collections::BTreeSet::new(); - let mut cid_faces = Vec::new(); - let mut uses_mojikumi = false; let mut missing: Vec<(FaceRef, Vec, designcraft_doc::ItemId)> = Vec::new(); for story in d.stories.values() { let cs = s.cache.get(d, story.id, None); missing_glyphs(&cs, None, 0, &mut missing); - if d.settings.use_cid_mojikumi { - rendered_faces(&cs, &mut cid_faces); - } if cs.is_overset() { let last = story.frames.last().copied(); let location = last.and_then(|f| overset_location(s, f)); @@ -162,7 +144,6 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { let ranges = story.para_ranges(); for (pi, pf) in story.paras.iter().enumerate() { let (para, base) = d.styles.resolve_para(pf); - uses_mojikumi |= !matches!(para.mojikumi.as_str(), "" | "Nothing" | "None"); let Some(range) = ranges.get(pi) else { continue }; let has_rtl = story.text.get(range.clone()).is_some_and(|text| text.chars().any(designcraft_fonts::is_rtl)); if has_rtl { @@ -226,14 +207,6 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { for f in missing_fonts { out.push(Issue { severity: "error", kind: "missingFont", message: format!("Missing font: {f}"), item: None, page: None }); } - if uses_mojikumi { - for face in cid_faces.iter().filter(|face| face.uses_japan1_cids()) { - unsupported_typography.insert(format!( - "CID-based Mojikumi requested for {} {}; Unicode classes are used because CID class mapping is not implemented", - face.family, face.style - )); - } - } for message in unsupported_typography { out.push(Issue { severity: "warning", kind: "unsupportedTypography", message, item: None, page: None }); } @@ -455,20 +428,3 @@ mod mojikumi_nested_tests { assert_eq!(issues.iter().filter(|i| i.kind == "unsupportedTypography" && i.message.contains("Unrecognized")).count(), 1); } } - -#[cfg(test)] -mod cid_preference_tests { - use super::*; - #[test] - fn cid_request_is_undoable_and_does_not_warn_for_unicode_only_fonts() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect":[0,0,300,200], "content":"text", "text":"ABC"})).unwrap(); - s.execute("text.select", &json!({"story":r["story"],"anchor":0,"focus":3})).unwrap(); - s.execute("type.para", &json!({"attrs":{"mojikumi":"SimpChineseDefault"}})).unwrap(); - assert_eq!(s.execute("document.preferences", &json!({"useCidMojikumi":true})).unwrap()["useCidMojikumi"], true); - assert!(!check(&s, 150.0).iter().any(|i| i.message.contains("CID-based"))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.execute("document.preferences", &json!({})).unwrap()["useCidMojikumi"], false); - } -} diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 9fc0fb5de..68a5cf152 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -21,7 +21,6 @@ const DOC_KEYS: &[&str] = &[ "advancedType", "overprintBlack", "glyphFallback", - "useCidMojikumi", ]; pub fn specs() -> Vec { @@ -63,7 +62,7 @@ pub fn specs() -> Vec { "Document Preferences", [], None, - "{horizontalUnits?, verticalUnits?: points|picas|inches|millimeters|…, keyboardIncrement? (pt), baselineGrid?: {start, increment, relativeTo, color, viewThreshold}, grid?: {horizontal, vertical, subdivisions, color, inBack}, pasteboard?: [h, v], marginColor?, columnColor?, bleedColor?, slugColor?: [r, g, b], advancedType?: {superscriptSize, superscriptPosition, subscriptSize, subscriptPosition} (%), overprintBlack?, useCidMojikumi? (preserve CID classification request; unsupported font collections are diagnosed), glyphFallback? (draw characters the font lacks from fallback fonts; off: as the font's missing-glyph box)} → those settings", + "{horizontalUnits?, verticalUnits?: points|picas|inches|millimeters|…, keyboardIncrement? (pt), baselineGrid?: {start, increment, relativeTo, color, viewThreshold}, grid?: {horizontal, vertical, subdivisions, color, inBack}, pasteboard?: [h, v], marginColor?, columnColor?, bleedColor?, slugColor?: [r, g, b], advancedType?: {superscriptSize, superscriptPosition, subscriptSize, subscriptPosition} (%), overprintBlack?, glyphFallback? (draw characters the font lacks from fallback fonts; off: as the font's missing-glyph box)} → those settings", has_doc, |s, p| { let cur = serde_json::to_value(&s.doc()?.doc.settings).map_err(|e| bad("document.preferences", e.to_string()))?; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 8c979e15c..2bc2b00ed 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -181,13 +181,6 @@ impl std::fmt::Debug for FontFace { } impl FontFace { - /// Adobe-Japan1 CFF fonts are the collection for CID-based Mojikumi. - /// Other CID collections still use Unicode classes under Adobe's policy. - pub fn uses_japan1_cids(&self) -> bool { - use skrifa::raw::TableProvider; - self.skrifa().and_then(|f| f.cff().ok()).is_some_and(cff_uses_japan1_cids) - } - pub fn data(&self) -> &[u8] { match &self.bytes { FontBytes::Static(b) => b, @@ -1574,31 +1567,3 @@ mod tests_docfonts; #[cfg(test)] #[path = "tests_vertical.rs"] mod tests_vertical; - -fn cff_uses_japan1_cids(cff: skrifa::raw::tables::cff::Cff<'_>) -> bool { - use skrifa::raw::ps::cff::dict; - let Some(top) = cff.top_dicts().get(0) else { return false }; - dict::entries(top, None).any(|entry| match entry { - Ok(dict::Entry::Ros { registry, ordering, .. }) => cff.string(registry) == Some(b"Adobe") && cff.string(ordering) == Some(b"Japan1"), - _ => false, - }) -} - -#[cfg(test)] -mod mojikumi_cid_tests { - use super::*; - use skrifa::raw::{FontData, FontRead, tables::cff::Cff}; - #[test] - fn recognizes_the_collection_in_cff_metadata_without_reading_outlines() { - // Minimal synthetic CFF indices: one name, a ROS dict, two strings, - // and one return-only subroutine. SID 391/392 reference the two strings. - let mut data = vec![1, 0, 4, 4, 0, 1, 1, 1, 2, b'T', 0, 1, 1, 1, 8, 248, 27, 248, 28, 139, 12, 30, 0, 2, 1, 1, 6, 12]; - data.extend_from_slice(b"AdobeJapan1"); - data.extend_from_slice(&[0, 1, 1, 1, 2, 11]); - assert!(cff_uses_japan1_cids(Cff::read(FontData::new(&data)).unwrap())); - let end = data.len() - 6; - data[end - 6..end].copy_from_slice(b"CNS1-X"); - assert!(!cff_uses_japan1_cids(Cff::read(FontData::new(&data)).unwrap())); - assert!(!FontDb::global().face(crate::DEFAULT_FAMILY, "Regular").uses_japan1_cids()); - } -} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index faa124ef8..8dd965473 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -799,7 +799,6 @@ impl<'a> Ex<'a> { let a = &s.advanced_type; root.push( El::new("TextPreference") - .attr("UseCidMojikumi", s.use_cid_mojikumi) .attr("SuperscriptSize", num(a.superscript_size)) .attr("SuperscriptPosition", num(a.superscript_position)) .attr("SubscriptSize", num(a.subscript_size)) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index aaef23dac..932062724 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -273,9 +273,6 @@ impl<'r> Importer<'r> { } } "TextPreference" => { - if let Some(v) = e.boolean("UseCidMojikumi") { - self.settings.use_cid_mojikumi = v; - } let a = &mut self.settings.advanced_type; for (k, v) in [ ("SuperscriptSize", &mut a.superscript_size), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 7aa9fc3c1..79f242483 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -968,11 +968,3 @@ fn malformed_mojikumi_rows_are_not_silently_coerced() { assert!(err.contains("Mojikumi"), "{key}={value}: {err}"); } } - -#[test] -fn cid_mojikumi_preference_survives_interchange() { - let mut d = import_idml(&fixture_with_story(STORY)).unwrap(); - d.settings.use_cid_mojikumi = true; - let restored = import_idml(&export_idml(&d)).unwrap(); - assert!(restored.settings.use_cid_mojikumi); -} diff --git a/docs/mojikumi.md b/docs/mojikumi.md index d3baa075c..c9390d58d 100644 --- a/docs/mojikumi.md +++ b/docs/mojikumi.md @@ -26,29 +26,8 @@ Regression tests cover preset boundary differences, custom override precedence, IDML reference round trips, and unsupported names. The implementation must not be represented as a byte-for-byte reproduction of Adobe's full preset matrices. -## Composition and validation +## Audit work -Spacing is attached to complete shaped clusters. Regional punctuation side -bearings, shaped narrow forms, tracking, manual Tsume and explicit character aki -are handled before line fitting. Continuous gaps of equal priority share one -allocation pool; non-floating gaps reach discrete endpoints. Leading-only -compression is included for ragged lines. Custom rows retain their dimensions. - -Import rejects malformed integers, non-finite amounts, inverted ranges and invalid -priorities. Native document validation uses the same range policy. Unknown integer -classes remain preservable and receive diagnostics, including in nested notes and -cells. The bounded supported spacing range is -1 to 100 em. - -## Compatibility limits - -[Adobe's TextPreference API](https://developer.adobe.com/indesign/uxp/omv/t/TextPreference/) -defines CID-based classification. The IDML `UseCidMojikumi` preference now survives -round trips and is accessible through `document.preferences`. The engine currently -uses Unicode classes. Preflight reports the incompatibility when the document -requests CID mode and actually renders Adobe-Japan1 CFF fonts with Mojikumi in use; -it does not flag ordinary Unicode fonts merely because the preference is enabled. - -Exact Adobe CID-to-Mojikumi class mapping, complete proprietary preset pair matrices, -and legacy pre-CS2 vertical-scaling compatibility have not been reproduced or -certified. Public CMap resources map character codes to CIDs; they are not a -CID-to-Mojikumi-class oracle. No claim of complete Adobe output parity is made. +Further audit areas are regional punctuation metrics, shaped cluster boundaries, +equal-priority allocation, discrete line fitting, input validation, and diagnostics +for compatibility modes whose glyph classification cannot yet be reproduced. From 9f655739fab096e6d809f4687d403dbf28cf4ac1 Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 20:36:21 +0800 Subject: [PATCH 12/12] CJK-REVERT2: withdraw speculative spacing rules and narrow import rejection Restore the previous preset and character-class coverage. Remove regional punctuation and Tsume policy changes while retaining cluster, tracking, and spacing-allocation repairs. Preserve finite unsupported rows for diagnostics instead of rejecting whole documents. --- ROADMAP.md | 7 +- crates/compose/src/mojikumi.rs | 6 +- crates/compose/src/shape.rs | 2 - crates/doc/src/lib.rs | 4 +- crates/doc/src/mojikumi.rs | 217 ++++----------------------------- crates/idml/src/import.rs | 23 ++-- crates/idml/src/tests.rs | 15 ++- docs/mojikumi.md | 43 +++---- 8 files changed, 70 insertions(+), 247 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 27d2e8928..eef038e6a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,10 +1,9 @@ # DesignCraft roadmap -- CJK-MOJI8: preserve shaped narrow punctuation and manual Tsume instead of restoring nonexistent full-width blank space. -- CJK-MOJI7: reject malformed Mojikumi interchange values and check nested note/cell paragraph rules in preflight. -- CJK-MOJI6: apply spacing at shaped-cluster boundaries and distinguish centered Traditional Chinese punctuation from Simplified Chinese side bearings. +- CJK-MOJI8: preserve shaped narrow punctuation when tracking changes its advance. +- CJK-MOJI7: prevent numeric coercion in Mojikumi import, preserve finite unsupported rules, and check nested note/cell rules in preflight. +- CJK-MOJI6: apply spacing at shaped-cluster boundaries without moving attached marks. - CJK-MOJI5: share equal-priority boundary spacing and honor discrete compression endpoints, including leading-only ragged-line fitting. -- CJK-MOJI4: resolve all public Mojikumi preset names and their distinct paragraph/line-edge policies; correct consecutive punctuation defaults and small-kana classes. - CJK-MOJI3: use the composition rule resolver in preflight, report unknown Mojikumi presets and retain regression coverage for supported and unsupported settings. diff --git a/crates/compose/src/mojikumi.rs b/crates/compose/src/mojikumi.rs index 0c44e546e..a1ca09c9e 100644 --- a/crates/compose/src/mojikumi.rs +++ b/crates/compose/src/mojikumi.rs @@ -10,8 +10,6 @@ pub struct Metrics { pub active: bool, /// Shaped body width before tracking and manual character spacing. pub body: f64, - pub tsume_before: f64, - pub tsume_after: f64, pub blank_before: f64, pub blank_after: f64, /// Desired internal gap attached to this glyph's advance. @@ -71,8 +69,8 @@ pub fn apply(glyphs: &mut [Glyph], styles: &Styles, name: &str) { // Tracking must not make a proportional or half-width glyph appear to // contain a removable half-em body. Shaping features already affect body. if body >= unit * 0.8 { - let before = if cluster[0].moji.explicit_before { 0.0 } else { (table.leading(class) * unit - cluster[0].moji.tsume_before).max(0.0) }; - let after = if cluster[0].moji.explicit_after { 0.0 } else { (table.trailing(class) * unit - cluster[0].moji.tsume_after).max(0.0) }; + let before = if cluster[0].moji.explicit_before { 0.0 } else { rules::leading(class) * unit }; + let after = if cluster[0].moji.explicit_after { 0.0 } else { rules::trailing(class) * unit }; let trim = before + after; if let Some(owner) = cluster.iter().position(|g| g.adv >= trim) { cluster[0].moji.blank_before = before / unit.max(1e-9); diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index bbf91cbb8..e1d46aa25 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -477,8 +477,6 @@ fn shape_run( let tsume = p.tsume.clamp(0.0, 1.0); let before = p.leading_aki.unwrap_or(0.0).clamp(0.0, 100.0) * em; let after = p.trailing_aki.unwrap_or(0.0).clamp(0.0, 100.0) * em; - g.moji.tsume_before = left * tsume; - g.moji.tsume_after = right * tsume; g.dx += before - left * tsume; g.adv += before + after - (left + right) * tsume; } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 33e0c7a9a..32e0e90cf 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -706,8 +706,8 @@ impl Document { } } for t in &self.styles.mojikumi_tables { - if t.overrides.iter().any(|r| !mojikumi::valid_range(r)) { - return Err(DocError::Invalid(format!("invalid mojikumi spacing: {}", t.name))); + if t.overrides.iter().any(|r| ![r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite())) { + return Err(DocError::Invalid(format!("non-finite mojikumi spacing: {}", t.name))); } } let mut ids = std::collections::HashSet::new(); diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index aaedd355a..fbb8a13df 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -19,92 +19,9 @@ impl Aki { } } -/// Adobe's public 16-preset enumeration. Japanese preset geometry is described -/// in the Adobe Mojikumi guide, sections 3.1–3.14 (see docs/mojikumi.md). -#[derive(Clone, Copy, Debug)] -struct Preset(u8); -impl Preset { - fn parse(name: &str) -> Option { - let name = name.trim_start_matches("$ID/"); - let names = [ - "LineEndAllOneHalfEmEnum", - "OneEmIndentLineEndUkeOneHalfEmEnum", - "OneOrOneHalfEmIndentLineEndUkeOneHalfEmEnum", - "OneOrOneHalfEmIndentLineEndAllOneEmEnum", - "OneEmIndentLineEndAllOneEmEnum", - "OneEmIndentLineEndAllNoFloatEnum", - "OneEmIndentLineEndUkeNoFloatEnum", - "OneOrOneHalfEmIndentLineEndUkeNoFloatEnum", - "OneEmIndentLineEndAllOneHalfEmEnum", - "LineEndAllOneEmEnum", - "LineEndUkeNoFloatEnum", - "OneOrOneHalfEmIndentLineEndPeriodOneEmEnum", - "OneEmIndentLineEndPeriodOneEmEnum", - "LineEndPeriodOneEmEnum", - "TradChineseDefault", - "SimpChineseDefault", - ]; - names.iter().position(|n| *n == name).map(|i| Self(i as u8 + 1)).or_else(|| { - let n = name.strip_prefix("kMojikumiDefaultName")?.parse::().ok()?; - (1..=16).contains(&n).then_some(Self(n)) - }) - } - fn leading(self, c: i16) -> f64 { - if self.0 == 15 && matches!(c, 6 | 21 | 30 | 31) { - 0.25 - } else if self.0 == 16 && c == 32 { - 0.0 - } else { - leading(c) - } - } - fn trailing(self, c: i16) -> f64 { - if self.0 == 15 && matches!(c, 6 | 21 | 30 | 31) { - 0.25 - } else if self.0 == 16 && c == 32 { - 0.5 - } else { - trailing(c) - } - } - fn boundary(self, left: i16, right: i16) -> Option { - let n = self.0; - let open = |c| matches!(c, 1 | 26 | 27); - if left == 23 { - if n >= 15 { - return Some(Aki::fixed(self.leading(right))); - } - let indent = if matches!(n, 1 | 10 | 11 | 14) { 0.0 } else { 1.0 }; - let opening = match n { - 2 | 5 | 7 | 13 => 1.5, - 3 | 8 | 10 => 0.5, - 4 | 6 | 9 | 12 => 1.0, - _ => 0.0, - }; - return Some(Aki::fixed(if open(right) { opening } else { indent + self.leading(right) })); - } - if left == 22 { - return Some(Aki::fixed(if n >= 15 || matches!(n, 4 | 5 | 10) { self.leading(right) } else { 0.0 })); - } - if right == 22 { - let width = self.trailing(left); - let full = n >= 15 || matches!(n, 4 | 5 | 10) || (matches!(n, 12..=14) && matches!(left, 6 | 31)); - let discrete = n == 6 || (matches!(n, 7 | 8 | 11) && matches!(left, 2 | 6 | 21 | 28..=31)); - return Some(if full { - Aki::fixed(width) - } else if discrete { - Aki { min: 0.0, desired: width, max: width, priority: 1, discrete: true } - } else { - Aki::fixed(0.0) - }); - } - None - } -} - /// Resolved table; custom rows override the base preset in document order. pub struct Rules<'a> { - preset: Preset, + chinese: bool, rows: &'a [MojikumiAki], } impl<'a> Rules<'a> { @@ -115,25 +32,25 @@ impl<'a> Rules<'a> { let key = name.strip_prefix("MojikumiTable/").unwrap_or(name); let table = styles.mojikumi_tables.iter().find(|t| t.name == key); let base = table.map_or(key, |t| if t.based_on.is_empty() { t.name.as_str() } else { t.based_on.as_str() }); - let preset = Preset::parse(base).ok_or_else(|| format!("unknown base preset `{base}`"))?; + let chinese = match base.trim_start_matches("$ID/") { + "SimpChineseDefault" | "TradChineseDefault" => true, + "LineEndAllOneHalfEmEnum" | "kMojikumiDefaultName1" => false, + _ => return Err(format!("unknown base preset `{base}`")), + }; let rows = table.map_or(&[][..], |t| t.overrides.as_slice()); for r in rows { if !valid_class(r.target_class) || !valid_class(r.side_class) { return Err(format!("unsupported character classes {}/{}", r.target_class, r.side_class)); } - if !valid_range(r) { + if ![r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite() && (-1.0..=100.0).contains(v)) + || r.minimum > r.desired + || r.desired > r.maximum + || !(0..=9).contains(&r.priority) + { return Err("invalid spacing range or priority".into()); } } - Ok(Some(Self { preset, rows })) - } - - /// Blank body portions for the preset's regional punctuation convention. - pub fn leading(&self, class: i16) -> f64 { - self.preset.leading(class) - } - pub fn trailing(&self, class: i16) -> f64 { - self.preset.trailing(class) + Ok(Some(Self { chinese, rows })) } fn override_pair(&self, left: i16, right: i16) -> Option<&MojikumiAki> { @@ -149,11 +66,11 @@ impl<'a> Rules<'a> { let mut aki = self.pair(left, right); if self.override_pair(left, right).is_none() { let missing = if matches!(left, 22 | 23) { - self.leading(right) - before_right + leading(right) - before_right } else if right == 22 { - self.trailing(left) - after_left + trailing(left) - after_left } else { - self.trailing(left).max(self.leading(right)) - after_left.max(before_right) + trailing(left).max(leading(right)) - after_left.max(before_right) } .max(0.0); aki.min = (aki.min - missing).max(0.0); @@ -173,17 +90,15 @@ impl<'a> Rules<'a> { discrete: row.does_not_float, }; } - if let Some(aki) = self.preset.boundary(left, right) { - return aki; + // Boundaries: Chinese keeps full punctuation; the half-em Japanese set + // trims opening punctuation at line start and closing punctuation at end. + if matches!(left, 22 | 23) { + return Aki::fixed(if self.chinese { leading(right) } else { 0.0 }); } - // Adjacent opening brackets and adjacent closing punctuation share a - // half-body, rather than restoring both glyphs to their full widths. - if (matches!(left, 1 | 26 | 27) && matches!(right, 1 | 26 | 27)) - || (matches!(left, 2 | 6 | 21 | 28..=31) && matches!(right, 2 | 6 | 21 | 28..=31)) - { - return Aki::fixed(0.0); + if right == 22 { + return Aki::fixed(if self.chinese { trailing(left) } else { 0.0 }); } - let punctuation = self.trailing(left).max(self.leading(right)); + let punctuation = trailing(left).max(leading(right)); if punctuation > 0.0 { return Aki { min: 0.0, desired: punctuation, max: punctuation, priority: 1, discrete: false }; } @@ -199,15 +114,6 @@ impl<'a> Rules<'a> { } } -/// Structural validation shared by native document loading and interchange. -/// Unknown integer classes remain preservable and are diagnosed by the resolver. -pub fn valid_range(r: &MojikumiAki) -> bool { - [r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite() && (-1.0..=100.0).contains(v)) - && r.minimum <= r.desired - && r.desired <= r.maximum - && (0..=9).contains(&r.priority) -} - pub fn valid_class(c: i16) -> bool { matches!(c, 1..=12 | 18 | 21..=33) } @@ -247,42 +153,13 @@ pub fn class(c: char) -> i16 { '¥' | '$' | '£' | '¥' => 8, '%' | '‰' | '℃' | '°' => 9, '\u{3000}' => 10, - 'ゕ' - | 'ゖ' - | 'ヵ' - | 'ヶ' - | '\u{31f0}'..='\u{31ff}' - | 'ぁ' - | 'ぃ' - | 'ぅ' - | 'ぇ' - | 'ぉ' - | 'っ' - | 'ゃ' - | 'ゅ' - | 'ょ' - | 'ゎ' - | 'ァ' - | 'ィ' - | 'ゥ' - | 'ェ' - | 'ォ' - | 'ッ' - | 'ャ' - | 'ュ' - | 'ョ' - | 'ヮ' - | 'ー' - | '々' - | 'ゝ' - | 'ゞ' - | 'ヽ' - | 'ヾ' => 3, + 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' + | 'ー' | '々' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' => 3, '\u{3040}'..='\u{309f}' => 11, - '\u{30a0}'..='\u{30ff}' | '\u{ff66}'..='\u{ff9d}' => 33, + '\u{30a0}'..='\u{30ff}' | '\u{31f0}'..='\u{31ff}' => 33, '0'..='9' => 24, '0'..='9' => 25, - '\u{2e80}'..='\u{a4cf}' | '\u{ac00}'..='\u{d7af}' | '\u{f900}'..='\u{faff}' | '\u{ff01}'..='\u{ff60}' | '\u{20000}'..='\u{3347f}' => 12, + '\u{2e80}'..='\u{a4cf}' | '\u{ac00}'..='\u{d7af}' | '\u{f900}'..='\u{faff}' | '\u{ff01}'..='\u{ff60}' | '\u{20000}'..='\u{323af}' => 12, _ => 18, } } @@ -292,47 +169,6 @@ mod tests { use super::*; use crate::cjk::MojikumiTable; - #[test] - fn regional_punctuation_retains_the_correct_side_bearings() { - let styles = Styles::default(); - let traditional = Rules::resolve(&styles, "TradChineseDefault").unwrap().unwrap(); - let simplified = Rules::resolve(&styles, "SimpChineseDefault").unwrap().unwrap(); - assert_eq!(traditional.leading(class('。')), 0.25); - assert_eq!(traditional.trailing(class('。')), 0.25); - assert_eq!(simplified.leading(class('。')), 0.0); - assert_eq!(simplified.trailing(class('。')), 0.5); - assert_eq!(traditional.pair(12, class('。')).desired, 0.25); - assert_eq!(simplified.pair(12, class('。')).desired, 0.0); - assert_eq!(simplified.leading(class(':')), 0.0); - assert_eq!(simplified.trailing(class(':')), 0.5); - assert!((simplified.pair_with_bodies(6, 12, 0.1, 0.0).desired - 0.1).abs() < 1e-9); - assert_eq!(simplified.pair_with_bodies(22, 27, 0.0, 0.0).desired, 0.0); - } - - #[test] - fn all_public_presets_have_distinct_documented_edges() { - let styles = Styles::default(); - let openings = [0.0, 1.5, 0.5, 1.0, 1.5, 1.0, 1.5, 0.5, 1.0, 0.5, 0.0, 1.0, 1.5, 0.0]; - for (i, expected) in openings.into_iter().enumerate() { - let rules = Rules::resolve(&styles, &format!("$ID/kMojikumiDefaultName{}", i + 1)).unwrap().unwrap(); - assert_eq!(rules.pair(23, 26).desired, expected, "preset {}", i + 1); - assert_eq!(rules.pair(23, 12).desired, if matches!(i + 1, 1 | 10 | 11 | 14) { 0.0 } else { 1.0 }); - } - let discrete = Rules::resolve(&styles, "OneEmIndentLineEndUkeNoFloatEnum").unwrap().unwrap(); - assert!(discrete.pair(6, 22).discrete); - assert!(!discrete.pair(5, 22).discrete); - let period = Rules::resolve(&styles, "LineEndPeriodOneEmEnum").unwrap().unwrap(); - assert_eq!(period.pair(6, 22).desired, 0.5); - assert_eq!(period.pair(21, 22).desired, 0.0); - for name in ["$ID/kMojikumiDefaultName15", "$ID/kMojikumiDefaultName16"] { - assert!(Rules::resolve(&styles, name).unwrap().is_some()); - } - assert_eq!(period.pair(26, 26).desired, 0.0); - assert_eq!(period.pair(28, 6).desired, 0.0); - assert_eq!(class('ㇰ'), 3); - assert_eq!(class('カ'), 33); - } - #[test] fn overrides_are_directional_and_unknown_tables_are_not_silently_applied() { let mut styles = Styles::default(); @@ -352,7 +188,6 @@ mod tests { }); let rule = Rules::resolve(&styles, "MojikumiTable/Pair").unwrap().unwrap(); assert_eq!(rule.pair(18, 12).desired, 0.2); - assert_eq!(rule.pair_with_bodies(18, 12, 0.0, 0.0).desired, 0.2); assert_eq!(rule.pair(12, 18).desired, 0.25); assert!(Rules::resolve(&styles, "Unknown").is_err()); assert!(Rules::resolve(&styles, "Nothing").unwrap().is_none()); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 932062724..fba7ae665 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -144,14 +144,14 @@ fn lab_to_color(l: f32, a: f32, b: f32) -> Color { } fn mojikumi_row(e: &El) -> Result { - let number = |key: &str, required: bool| -> Result { + let number = |key: &str| -> Result { let Some(raw) = e.prop(key) else { - return if required { Err(IdmlError::Invalid(format!("Mojikumi row is missing {key}"))) } else { Ok(0.0) }; + return Ok(0.0); }; raw.trim().parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| IdmlError::Invalid(format!("Mojikumi row has invalid {key}"))) }; - let integer = |key: &str, required: bool| -> Result { - let v = number(key, required)?; + let integer = |key: &str| -> Result { + let v = number(key)?; if v.fract() != 0.0 || v < f64::from(i16::MIN) || v > f64::from(i16::MAX) { return Err(IdmlError::Invalid(format!("Mojikumi {key} must be an in-range integer"))); } @@ -165,18 +165,15 @@ fn mojikumi_row(e: &El) -> Result { } }; let row = designcraft_doc::cjk::MojikumiAki { - target_class: integer("TargetMojikumiClass", true)?, - side_class: integer("SideMojikumiClass", true)?, + target_class: integer("TargetMojikumiClass")?, + side_class: integer("SideMojikumiClass")?, after: boolean("SideIsAfterTarget")?, - minimum: number("Minimum", false)?, - desired: number("Desired", false)?, - maximum: number("Maximum", false)?, - priority: integer("CompressionPriority", false)?, + minimum: number("Minimum")?, + desired: number("Desired")?, + maximum: number("Maximum")?, + priority: integer("CompressionPriority")?, does_not_float: boolean("AkiDoesNotFloat")?, }; - if !designcraft_doc::mojikumi::valid_range(&row) { - return Err(IdmlError::Invalid("Mojikumi row has an invalid spacing range or priority".into())); - } Ok(row) } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 79f242483..11fb11b8b 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -947,6 +947,7 @@ fn malformed_mojikumi_rows_are_not_silently_coerced() { ("Desired", "oops"), ("Minimum", "0.4"), ("Maximum", "0.1"), + ("Maximum", "101"), ("CompressionPriority", "10"), ("AkiDoesNotFloat", "maybe"), ] { @@ -964,7 +965,17 @@ fn malformed_mojikumi_rows_are_not_silently_coerced() { ("Spreads/Spread_sp1.xml", SPREAD), ("Stories/Story_s1.xml", STORY), ]); - let err = import_idml(&bytes).unwrap_err().to_string(); - assert!(err.contains("Mojikumi"), "{key}={value}: {err}"); + if matches!((key, value), ("Minimum", "0.4") | ("Maximum", "0.1" | "101") | ("CompressionPriority", "10")) { + // Finite, representable rules survive import even if composition + // cannot apply them. The resolver supplies a diagnostic instead. + let document = import_idml(&bytes).unwrap(); + document.check().unwrap(); + assert!(designcraft_doc::mojikumi::Rules::resolve(&document.styles, "MojikumiTable/Bad").is_err()); + let restored = import_idml(&export_idml(&document)).unwrap(); + assert_eq!(restored.styles.mojikumi_tables, document.styles.mojikumi_tables); + } else { + let err = import_idml(&bytes).unwrap_err().to_string(); + assert!(err.contains("Mojikumi"), "{key}={value}: {err}"); + } } } diff --git a/docs/mojikumi.md b/docs/mojikumi.md index c9390d58d..fcdf28074 100644 --- a/docs/mojikumi.md +++ b/docs/mojikumi.md @@ -1,33 +1,18 @@ -# Mojikumi implementation and public references +# Mojikumi repair scope -This is a clean-room implementation of public spacing behavior. Mojikumi is an -Adobe feature, not a single conformance standard. W3C JLReq and CLReq describe -Japanese and Chinese layout requirements; they do not specify Adobe's private -composer or its complete preset pair matrices. +The retained changes address reproducible implementation defects: equal-priority +spacing allocation, discrete compression endpoints, leading-only compression, +spacing after complete shaped clusters, and tracking that made narrow punctuation +look full-width. Preflight also checks nested note and table-cell paragraph rules. -References: +IDML import rejects malformed/non-finite numbers and integer coercion. Finite, +representable rules outside the composer's supported range remain preserved; +the rule resolver reports unsupported settings instead of rejecting the document. -- [Adobe preset enumeration](https://developer.adobe.com/indesign/uxp/dom/api/m/mojikumi-table-defaults/) -- [Adobe Mojikumi guide, sections 3–5](https://wwwimages.adobe.com/www.adobe.com/jp/joc/design/guides/pdf/mojikumi.pdf) -- [Adobe spacing priorities](https://helpx.adobe.com/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/set-spacing-priorities-in-mojikumi-character-classes.html) -- [W3C JLReq](https://www.w3.org/TR/jlreq/), especially character classes and line adjustment -- [W3C CLReq](https://www.w3.org/TR/clreq/), especially regional punctuation placement +The broad preset expansion, regional side-bearing changes, Tsume combination +policy, and CID preference/diagnostic extension were withdrawn after review. +The original supported preset set and Unicode classification remain in place. +No claim of full Adobe Mojikumi conformance or exact output parity is made. -## Presets and interchange - -The resolver recognizes the 14 Japanese presets and two Chinese presets from the -public enumeration, including their IDML resource aliases. Paragraph indentation, -opening-bracket indentation, ordinary line starts, full/half/discrete line ends, -and period-only line-end policies are represented separately. Custom IDML rows -replace the corresponding directional pair. Adjacent opening and closing marks -must not regain two full blank half-bodies. - -Regression tests cover preset boundary differences, custom override precedence, -IDML reference round trips, and unsupported names. The implementation must not be -represented as a byte-for-byte reproduction of Adobe's full preset matrices. - -## Audit work - -Further audit areas are regional punctuation metrics, shaped cluster boundaries, -equal-priority allocation, discrete line fitting, input validation, and diagnostics -for compatibility modes whose glyph classification cannot yet be reproduced. +Reference for priority semantics: +[Adobe spacing priorities](https://helpx.adobe.com/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/set-spacing-priorities-in-mojikumi-character-classes.html).