mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 03:06:56 -06:00
Merge #126: Fix composite font resolution, missing-glyph diagnostics, and document-wide font replacement
Conflict: crates/compose/src/shape.rs: #81 collects paint-only pieces and shapes them later, #126 shapes composite-font note labels inline. A footnote reference in a composite font becomes a Piece with `composite_note`, shaped alone in the piece pass with #126's per-entry label code (from the run's env, as in #126); other references stay ordinary pieces. #126's label fallback in shape_segment passes #81's StyleRuns. Conflict: crates/fonts/src/fontdb.rs: #126's canonical_family lookup sits at the top of exact_style_traits, so #81's styles/traits_of/linked_style and #247's format-suffix fallback all use it; face() resolves canonical, then canonical without the format suffix, then #126's substitute_family, through #247's find_or_load; has_family is canonical_family as named or without the suffix Conflict: crates/ui-egui/src/lib.rs: kept #84's system-clipboard copy match and added #126's Find Font on open and _fonts reset after it Conflict: crates/ui-egui/src/dialogs.rs: OK label match has #146's Save and #126's Replace All; #80's alert-only OK kept, #126's Done label for findFont; find_font keeps #143's font_combo with #126's FALLBACK_FAMILY default Conflict: crates/compose/src/tests.rs, crates/idml/src/tests.rs: tests only; kept both Conflict: ROADMAP.md: kept both entries
This commit is contained in:
+11
@@ -2,9 +2,20 @@
|
||||
|
||||
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -102,6 +102,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,
|
||||
@@ -2132,6 +2134,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,
|
||||
@@ -2235,6 +2238,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,
|
||||
|
||||
@@ -22,6 +22,7 @@ fn set(base: &PlacedGlyph, text: &str, size: f64, base_size: f64) -> (Vec<Placed
|
||||
let ch = text[sg.cluster..].chars().next().unwrap_or(' ');
|
||||
let adv = sg.x_advance as f64 * k;
|
||||
out.push(PlacedGlyph {
|
||||
rendered_char: ch,
|
||||
gid: sg.gid,
|
||||
x: x + sg.x_offset as f64 * k,
|
||||
y: y - sg.y_offset as f64 * k,
|
||||
|
||||
@@ -59,6 +59,8 @@ pub struct Glyph {
|
||||
pub leading_model: designcraft_doc::cjk::LeadingModel,
|
||||
pub character_direction: designcraft_doc::arabic::CharacterDirection,
|
||||
pub display_char: char,
|
||||
/// Actual shaped character, including generated labels, for glyph diagnostics.
|
||||
pub rendered_char: char,
|
||||
pub bidi_offset: usize,
|
||||
pub bidi_end: usize,
|
||||
pub allow_kashidas: bool,
|
||||
@@ -101,6 +103,9 @@ struct Piece {
|
||||
env: TypeEnv,
|
||||
/// Hidden conditional text or tracked deletions: laid out as zero-width place-holders.
|
||||
hidden: bool,
|
||||
/// A note reference set in a composite font: its label is shaped alone, each part in the
|
||||
/// composite entry's font.
|
||||
composite_note: bool,
|
||||
}
|
||||
|
||||
/// The style of each byte of a shaped run: `(first byte, style)` in story order.
|
||||
@@ -303,8 +308,7 @@ pub(crate) fn shape_para(
|
||||
None => 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() {
|
||||
@@ -327,12 +331,12 @@ pub(crate) fn shape_para(
|
||||
let auto_leading = table.env_for(auto_leading, style);
|
||||
let deleted = props.change == designcraft_doc::ChangeMark::Deleted;
|
||||
if deleted || (!props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c))) {
|
||||
pieces.push(Piece { range: a..b, props, style, env: auto_leading, hidden: true });
|
||||
pieces.push(Piece { range: a..b, props, style, env: auto_leading, hidden: true, composite_note: false });
|
||||
continue;
|
||||
}
|
||||
let is_ref = |c: char| c == designcraft_doc::FOOTNOTE_REF || c == designcraft_doc::ENDNOTE_REF;
|
||||
if !story.text[a..b].contains(is_ref) {
|
||||
pieces.push(Piece { range: a..b, props, style, env: auto_leading, hidden: false });
|
||||
pieces.push(Piece { range: a..b, props, style, env: auto_leading, hidden: false, composite_note: false });
|
||||
continue;
|
||||
}
|
||||
// Footnote references take the reference position / character style; endnote references
|
||||
@@ -354,18 +358,24 @@ pub(crate) fn shape_para(
|
||||
for (i, m) in story.text[a..b].match_indices(is_ref) {
|
||||
let i = a + i;
|
||||
if k < i {
|
||||
pieces.push(Piece { range: k..i, props: props.clone(), style, env: auto_leading, hidden: false });
|
||||
pieces.push(Piece { range: k..i, props: props.clone(), style, env: auto_leading, hidden: false, composite_note: false });
|
||||
}
|
||||
let e = i + m.len();
|
||||
if m.starts_with(designcraft_doc::ENDNOTE_REF) {
|
||||
pieces.push(Piece { range: i..e, props: eprops.clone(), style: estyle, env: eenv, hidden: false });
|
||||
pieces.push(Piece { range: i..e, props: eprops.clone(), style: estyle, env: eenv, hidden: false, composite_note: false });
|
||||
} else {
|
||||
pieces.push(Piece { range: i..e, props: rprops.clone(), style: rstyle, env: renv, hidden: false });
|
||||
if styles.composite_font(&rprops.font_family).is_some() {
|
||||
// Shaped entry by entry below, from the run's env: the composite font's own
|
||||
// name is never an installed family, so `renv` would force glyph fallback.
|
||||
pieces.push(Piece { range: i..e, props: rprops.clone(), style: rstyle, env: auto_leading, hidden: false, composite_note: true });
|
||||
} else {
|
||||
pieces.push(Piece { range: i..e, props: rprops.clone(), style: rstyle, env: renv, hidden: false, composite_note: false });
|
||||
}
|
||||
}
|
||||
k = e;
|
||||
}
|
||||
if k < b {
|
||||
pieces.push(Piece { range: k..b, props, style, env: auto_leading, hidden: false });
|
||||
pieces.push(Piece { range: k..b, props, style, env: auto_leading, hidden: false, composite_note: false });
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
@@ -387,12 +397,42 @@ pub(crate) fn shape_para(
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if first.composite_note {
|
||||
if let Some(composite) = styles.composite_font(&first.props.font_family) {
|
||||
let at = first.range.start;
|
||||
let label = sub.notes.get(&at).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, &first.props, entry);
|
||||
let style = table.intern(db, &props);
|
||||
let env = table.env_for(first.env, style);
|
||||
let face = db.face(&props.font_family, &props.font_style);
|
||||
shape_segment(db, &story.text, first.range.clone(), label.get(a..b), &props, &face, env, &StyleRuns(&[(at, style)]), &mut glyphs, sub.vertical);
|
||||
}
|
||||
for (index, glyph) in glyphs[begin..].iter_mut().enumerate() {
|
||||
glyph.len = if index == 0 { first.range.len() } else { 0 };
|
||||
glyph.no_break = true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let key = shaping_key(&first.props);
|
||||
let mut j = i + 1;
|
||||
// Jidori fits its own run into whole ems: such pieces stay apart.
|
||||
while j < pieces.len()
|
||||
&& first.props.jidori == 0
|
||||
&& !pieces[j].hidden
|
||||
&& !pieces[j].composite_note
|
||||
&& pieces[j].range.start == pieces[j - 1].range.end
|
||||
&& pieces[j].env.glyph_fallback == first.env.glyph_fallback
|
||||
&& shaping_key(&pieces[j].props) == key
|
||||
@@ -894,6 +934,7 @@ fn control_glyph(face: &Arc<FontFace>, 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,
|
||||
@@ -919,7 +960,32 @@ fn shape_segment(
|
||||
out: &mut Vec<Glyph>,
|
||||
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<FontFace>)> = 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, styles, 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;
|
||||
@@ -1014,6 +1080,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 {
|
||||
|
||||
@@ -3162,3 +3162,85 @@ fn inline_object_only_line_does_not_reserve_phantom_font_descent() {
|
||||
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::<String>(), "[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");
|
||||
}
|
||||
|
||||
@@ -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" }))
|
||||
|
||||
+235
-52
@@ -13,7 +13,7 @@ use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
|
||||
/// 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<Value> {
|
||||
used_fonts(d).into_iter().map(|font| json!(font)).collect()
|
||||
}
|
||||
|
||||
pub(super) fn used_fonts(d: &Document) -> Vec<UsedFont> {
|
||||
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<char>)> = 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<Value> {
|
||||
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<UsedFont> = 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<char> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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();
|
||||
@@ -226,3 +314,98 @@ mod tests {
|
||||
assert!(kinds(&mut s).iter().any(|k| k == "missingFont"));
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,35 +38,35 @@ fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option<usize> {
|
||||
/// 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<char>)>) {
|
||||
// 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<designcraft_doc::ItemId>,
|
||||
depth: usize,
|
||||
out: &mut Vec<(FaceRef, Vec<char>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,13 +115,12 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
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<String> = Vec::new();
|
||||
let mut unsupported_typography = std::collections::BTreeSet::new();
|
||||
let mut missing: Vec<(FaceRef, Vec<char>)> = Vec::new();
|
||||
let mut missing: Vec<(FaceRef, Vec<char>, 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 location = last.and_then(|f| overset_location(s, f));
|
||||
@@ -163,7 +162,6 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
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
|
||||
@@ -180,26 +178,33 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
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<String> = 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() };
|
||||
@@ -207,8 +212,8 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
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.
|
||||
@@ -372,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);
|
||||
}
|
||||
}
|
||||
|
||||
+133
-21
@@ -125,6 +125,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<String>)>,
|
||||
family_aliases: Vec<String>,
|
||||
style_aliases: Vec<String>,
|
||||
}
|
||||
|
||||
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
|
||||
@@ -211,15 +213,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);
|
||||
}
|
||||
}
|
||||
@@ -228,7 +238,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 {
|
||||
@@ -308,6 +318,7 @@ struct CatalogEntry {
|
||||
/// The font menu group and native family name, read by the scan.
|
||||
group: FontGroup,
|
||||
native: Option<String>,
|
||||
aliases: Vec<String>,
|
||||
}
|
||||
|
||||
/// What the scan reads of one face in a font file: its names and font menu group.
|
||||
@@ -318,6 +329,7 @@ struct ScannedFace {
|
||||
style: String,
|
||||
group: FontGroup,
|
||||
native: Option<String>,
|
||||
aliases: Vec<String>,
|
||||
}
|
||||
|
||||
/// Process-wide font database: the fonts every document shares (bundled, installed, added), and
|
||||
@@ -496,6 +508,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<String> {
|
||||
let mut names: Vec<String> =
|
||||
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<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
}
|
||||
@@ -571,7 +591,7 @@ fn file_face_names(path: &std::path::Path) -> Vec<ScannedFace> {
|
||||
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()
|
||||
}
|
||||
@@ -693,6 +713,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,
|
||||
@@ -720,6 +742,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(),
|
||||
})
|
||||
}
|
||||
@@ -965,7 +989,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1119,6 +1150,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))
|
||||
}
|
||||
@@ -1234,6 +1266,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<String> {
|
||||
let matches =
|
||||
|f: &&Arc<FontFace>| 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<FontFace> {
|
||||
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<String> {
|
||||
@@ -1320,6 +1435,8 @@ impl ScopedFonts<'_> {
|
||||
}
|
||||
|
||||
fn exact_style_traits(&self, family: &str) -> Vec<(bool, f32, String)> {
|
||||
let canonical = self.canonical_family(family);
|
||||
let family = canonical.as_deref().unwrap_or(family);
|
||||
let mut v: Vec<(bool, f32, String)> = self
|
||||
.own()
|
||||
.iter()
|
||||
@@ -1342,7 +1459,11 @@ impl ScopedFonts<'_> {
|
||||
/// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. A
|
||||
/// family with a format suffix (`Minion Pro (OTF)`) that isn't found is looked up without it.
|
||||
pub fn face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
||||
if let Some(f) = self.find_or_load(family, style).or_else(|| without_format_suffix(family).and_then(|base| self.find_or_load(base, style))) {
|
||||
let canonical = self
|
||||
.canonical_family(family)
|
||||
.or_else(|| without_format_suffix(family).and_then(|base| self.canonical_family(base)))
|
||||
.unwrap_or_else(|| self.substitute_family(without_format_suffix(family).unwrap_or(family)));
|
||||
if let Some(f) = self.find_or_load(&canonical, style) {
|
||||
return f;
|
||||
}
|
||||
self.find(FALLBACK_FAMILY, style)
|
||||
@@ -1365,18 +1486,7 @@ impl ScopedFonts<'_> {
|
||||
/// Is `family` available (the document's, loaded, or installed on the system), as named or
|
||||
/// without a format suffix (see [`ScopedFonts::face`])?
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
self.has_exact_family(family) || without_format_suffix(family).is_some_and(|base| self.has_exact_family(base))
|
||||
}
|
||||
|
||||
fn has_exact_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() || without_format_suffix(family).is_some_and(|base| self.canonical_family(base).is_some())
|
||||
}
|
||||
|
||||
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
|
||||
@@ -1440,12 +1550,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<FontFace>| f.family.eq_ignore_ascii_case(family);
|
||||
let cands: Vec<&Arc<FontFace>> = self.own().iter().filter(named).chain(faces.iter().filter(named)).collect();
|
||||
let own: Vec<&Arc<FontFace>> = self.own().iter().filter(named).collect();
|
||||
let cands: Vec<&Arc<FontFace>> = 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));
|
||||
|
||||
@@ -558,3 +558,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1022,9 +1022,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);
|
||||
|
||||
@@ -104,6 +104,7 @@ struct Importer<'r> {
|
||||
inks: designcraft_doc::InkManager,
|
||||
stroke_styles: Vec<designcraft_doc::StrokeStyleDef>,
|
||||
styles: Styles,
|
||||
composite_names: HashMap<String, String>,
|
||||
kinsoku: HashMap<String, designcraft_doc::cjk::Kinsoku>,
|
||||
para_names: HashMap<String, String>,
|
||||
char_names: HashMap<String, String>,
|
||||
@@ -191,6 +192,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(),
|
||||
@@ -276,6 +278,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 {
|
||||
@@ -290,7 +297,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() {
|
||||
@@ -1051,7 +1058,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");
|
||||
|
||||
@@ -1450,3 +1450,40 @@ fn round_trips_anchored_text_frames_in_body_and_footnotes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_references_resolve_by_identity_and_export_as_objects() {
|
||||
let map = DESIGNMAP.replace("</Document>", r#"
|
||||
<CompositeFont Self="CompositeFont/opaque%3a17" Name="Mixed%3a literal">
|
||||
<CompositeFontEntry Name="Base" FontStyle="$ID/Regular"><Properties><AppliedFont type="string">Source Serif 4</AppliedFont></Properties></CompositeFontEntry>
|
||||
<CompositeFontEntry Name="Digits" CustomCharacters="0123456789" FontStyle="$ID/Bold" RelativeSize="80" HorizontalScale="90" VerticalScale="110" BaselineShift="5" ScaleOption="false"><Properties><AppliedFont type="string">Source Sans 3</AppliedFont></Properties></CompositeFontEntry>
|
||||
</CompositeFont></Document>"#);
|
||||
let styles = STYLES
|
||||
.replace("<AppliedFont type=\"string\">Source Serif 4</AppliedFont>", "<AppliedFont type=\"object\">CompositeFont/opaque%3a17</AppliedFont>");
|
||||
let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Content>A1</Content><Footnote><ParagraphStyleRange><CharacterStyleRange><Content>B2</Content></CharacterStyleRange></ParagraphStyleRange></Footnote></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#;
|
||||
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("<AppliedFont type=\"object\">CompositeFont/Mixed%253a literal</AppliedFont>"), "{xml}");
|
||||
let back = import_idml(&output).unwrap();
|
||||
assert_eq!(back.styles.composite_fonts, d.styles.composite_fonts);
|
||||
}
|
||||
|
||||
@@ -1391,7 +1391,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
_ => {}
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
let ok_label = if d.id == "closeDocument" { " Save " } else { " OK " };
|
||||
let ok_label = match d.id.as_str() {
|
||||
"closeDocument" => " Save ",
|
||||
"findFont" => "Replace All",
|
||||
_ => " OK ",
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
@@ -1405,7 +1409,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
result = Some(true);
|
||||
}
|
||||
// An alert only has OK.
|
||||
let cancel = d.id != "alert" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked();
|
||||
let cancel = d.id != "alert" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if d.id == "findFont" { "Done" } else { "Cancel" }))).clicked();
|
||||
if cancel || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
result = Some(false);
|
||||
}
|
||||
@@ -1733,11 +1737,19 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
}
|
||||
"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");
|
||||
@@ -3193,6 +3205,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,
|
||||
@@ -3207,8 +3237,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));
|
||||
@@ -3216,9 +3246,29 @@ 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);
|
||||
if d.s("toFamily").is_empty() {
|
||||
d.fields.insert("toFamily".into(), json!(designcraft_fonts::FALLBACK_FAMILY));
|
||||
}
|
||||
egui::Grid::new("ff_to").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:"));
|
||||
if let Some(f) = crate::panels::font_combo(app, ui, "toFamily", &d.s("toFamily"), 170.0) {
|
||||
@@ -3226,6 +3276,17 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
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);
|
||||
@@ -3233,9 +3294,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),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -564,6 +564,24 @@ impl DesignApp {
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
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");
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
@@ -1208,6 +1226,40 @@ mod tests {
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user