mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
CJK-BASE: integrate font resolution and special-text preflight branches
This commit is contained in:
+12
@@ -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.
|
||||
|
||||
@@ -249,7 +249,14 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
|
||||
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) {
|
||||
|
||||
+27
-10
@@ -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<designcraft_doc::AnchoredObject>,
|
||||
/// 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<FrameSpec> {
|
||||
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<FrameSpec> {
|
||||
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<FrameSpec> {
|
||||
_ => (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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -54,6 +54,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,
|
||||
@@ -234,8 +236,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() {
|
||||
@@ -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<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,
|
||||
@@ -803,7 +832,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, 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 {
|
||||
|
||||
@@ -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::<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");
|
||||
}
|
||||
|
||||
@@ -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()?)?);
|
||||
|
||||
+46
-2
@@ -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<StoryId, Vec<StoryId>> = 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<StoryId, Vec<StoryId>>,
|
||||
path: &mut Vec<StoryId>,
|
||||
done: &mut std::collections::HashMap<StoryId, usize>,
|
||||
) -> Result<usize> {
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,56 +38,105 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<usize>)> {
|
||||
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<Issue> {
|
||||
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<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 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<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
|
||||
@@ -130,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() };
|
||||
@@ -157,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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
+130
-16
@@ -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<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
|
||||
@@ -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<String>,
|
||||
aliases: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
aliases: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<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()))
|
||||
}
|
||||
@@ -567,7 +587,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()
|
||||
}
|
||||
@@ -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<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> {
|
||||
@@ -1218,6 +1333,8 @@ impl ScopedFonts<'_> {
|
||||
|
||||
/// Style names available for `family` (Regular first, then by weight).
|
||||
pub fn styles(&self, family: &str) -> Vec<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()
|
||||
@@ -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<FontFace> {
|
||||
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<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));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+19
-10
@@ -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>,
|
||||
@@ -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<String, ItemId> = self.item_ids.clone();
|
||||
let mut per_story: BTreeMap<StoryId, Vec<usize>> = 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);
|
||||
}
|
||||
|
||||
@@ -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("</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);
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
drawing_items: Vec<designcraft_doc::ItemId>,
|
||||
images: HashMap<AssetId, Option<Image>>,
|
||||
/// Placed PDFs (embedded as vector pages).
|
||||
pdfs: HashMap<AssetId, Option<krilla::pdf::PdfDocument>>,
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,3 +337,32 @@ fn missing_glyphs_print_as_boxes() {
|
||||
assert_eq!(glyphs.iter().map(|g| g.0.as_str()).collect::<Vec<_>>(), ["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:?}");
|
||||
}
|
||||
|
||||
@@ -217,6 +217,7 @@ pub struct Renderer {
|
||||
glyphs: text::GlyphCache,
|
||||
/// Composed stories looked up during the current render.
|
||||
stories: HashMap<(StoryId, Option<String>), Arc<ComposedStory>>,
|
||||
drawing_items: Vec<designcraft_doc::ItemId>,
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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");
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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