From 20ce4f5ecdb192709231251f896571bdc07bc65d Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 20:24:59 +0800 Subject: [PATCH] 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.