Merge pull request #85 from aiongg/feat/cjk-and-fonts
CJK: language-aware shaping and fallback, vertical metrics, document fonts, missing-glyph boxes (builds on #75)
This commit is contained in:
commit
1c2aa451cf
12
ROADMAP.md
12
ROADMAP.md
|
|
@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
|
||||
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
|
||||
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
|
||||
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
|
||||
- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample).
|
||||
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
|
||||
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
|
||||
|
|
@ -65,7 +66,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates.
|
||||
- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption.
|
||||
- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset.
|
||||
- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face.
|
||||
- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order.
|
||||
- Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json).
|
||||
- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label).
|
||||
- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name.
|
||||
|
|
@ -87,7 +88,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
|
||||
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
|
||||
- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate.
|
||||
- Language (Character panel ▸ Language, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, and typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, CJK 「…」). Hyphenation dictionaries for other languages aren't bundled yet.
|
||||
- Language (Character panel ▸ Language, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Hyphenation dictionaries for other languages aren't bundled yet.
|
||||
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
|
||||
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
|
||||
- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change.
|
||||
|
|
@ -111,7 +112,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
- Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout.
|
||||
- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture.
|
||||
- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread.
|
||||
- Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text). Punctuation and brackets take their vertical forms (OpenType `vert`/`vrt2`) in vertical frames. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Vertical metrics (`vmtx`), warichu and ruby options (per-character ruby, positions, overhang) aren't there yet.
|
||||
- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet.
|
||||
- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited.
|
||||
- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers.
|
||||
- Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted).
|
||||
|
|
@ -146,7 +147,8 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded.
|
||||
- Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML).
|
||||
- Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name).
|
||||
- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, an IDML copy, an optional PDF and a report (fonts, links, preflight, instructions); Copy Links To (`links.copyTo`).
|
||||
- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`).
|
||||
- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read.
|
||||
- Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`.
|
||||
- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`.
|
||||
- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`.
|
||||
|
|
@ -184,7 +186,7 @@ four agents in parallel on separate crates:
|
|||
|---|---|
|
||||
| Open P0 (2: PDF/X-4 validation against a certified checker, PostScript interpretation for EPS) | 8 h |
|
||||
| Open P1 (none partial) | 0 h |
|
||||
| Open P2 (4 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback, vertical metrics and warichu) | 14 h |
|
||||
| Open P2 (4 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback, warichu) | 14 h |
|
||||
| Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h |
|
||||
| Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 55 h |
|
||||
|
||||
|
|
|
|||
|
|
@ -20,4 +20,5 @@ regex = { workspace = true }
|
|||
miniz_oxide = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
proptest = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ pub struct Spacing {
|
|||
pub hyph_zone: f64,
|
||||
/// Optical margin alignment: punctuation hangs outside the measure (counted as extra width).
|
||||
pub optical: bool,
|
||||
/// Korean text breaks between syllables instead of at spaces.
|
||||
pub korean_char_breaks: bool,
|
||||
}
|
||||
|
||||
impl Default for Spacing {
|
||||
|
|
@ -68,6 +70,7 @@ impl Default for Spacing {
|
|||
ragged_stretch: 24.0,
|
||||
hyph_zone: 0.0,
|
||||
optical: false,
|
||||
korean_char_breaks: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -168,15 +171,41 @@ const INF: f64 = 10000.0;
|
|||
/// hangul), except where kinsoku forbids it: no line starts with closing punctuation, small
|
||||
/// kana or the prolonged sound mark, and none ends with opening brackets.
|
||||
pub fn cjk_break_between(a: char, b: char) -> bool {
|
||||
let cjk = |c: char| crate::upright_in_vertical(c) || matches!(c as u32, 0x3000..=0x303F);
|
||||
if !(cjk(a) || cjk(b)) {
|
||||
return false;
|
||||
}
|
||||
(is_cjk(a) || is_cjk(b)) && kinsoku_allows(a, b)
|
||||
}
|
||||
|
||||
fn is_cjk(c: char) -> bool {
|
||||
crate::upright_in_vertical(c) || matches!(c as u32, 0x3000..=0x303F)
|
||||
}
|
||||
|
||||
fn kinsoku_allows(a: char, b: char) -> bool {
|
||||
const NO_START: &str = "、。,.・:;?!ー)」』】〕〉》}]〙〗ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ々〻ゝゞヽヾ!),.:;?]}";
|
||||
const NO_END: &str = "(「『【〔〈《{[〘〖([{";
|
||||
!NO_START.contains(b) && !NO_END.contains(a)
|
||||
}
|
||||
|
||||
/// Hangul, and Han ideographs (hanja) in Korean text: Korean is set word by word (KLREQ), so
|
||||
/// these letters break like Western ones, at spaces.
|
||||
fn korean_letter(g: &Glyph) -> bool {
|
||||
let c = g.ch as u32;
|
||||
let hangul = matches!(c, 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF);
|
||||
let han = matches!(c, 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x3FFFF);
|
||||
hangul || (han && g.lang.is_some_and(|t| designcraft_doc::language_subtag(t) == "ko"))
|
||||
}
|
||||
|
||||
/// Is the place between glyphs `a` and `b` one where CJK text may break (next to an ideograph, kana
|
||||
/// or hangul)? Korean letters take no breaks between characters unless the paragraph asks for
|
||||
/// character-based breaking.
|
||||
pub(crate) fn cjk_pair(a: &Glyph, b: &Glyph, korean_char_breaks: bool) -> bool {
|
||||
let cjk = |g: &Glyph| is_cjk(g.ch) && (korean_char_breaks || !korean_letter(g));
|
||||
cjk(a) || cjk(b)
|
||||
}
|
||||
|
||||
/// [`cjk_break_between`] for glyphs `a` and `b` (see [`cjk_pair`]).
|
||||
fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool {
|
||||
cjk_pair(a, b, sp.korean_char_breaks) && kinsoku_allows(a.ch, b.ch)
|
||||
}
|
||||
|
||||
pub fn is_forced(c: char) -> bool {
|
||||
use designcraft_doc::story::*;
|
||||
matches!(c, FORCED_LINE_BREAK | COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK)
|
||||
|
|
@ -310,7 +339,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec
|
|||
let mut p = Item::penalty(0.0, 50.0, true);
|
||||
p.hang = right_hang(g, sp);
|
||||
push_inword(&mut it, &mut ig, p, i + 1, sp);
|
||||
} else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break_between(g.ch, glyphs[i + 1].ch)) {
|
||||
} else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp)) {
|
||||
let mut p = Item::penalty(0.0, 0.0, false);
|
||||
p.hang = right_hang(g, sp);
|
||||
push_inword(&mut it, &mut ig, p, i + 1, sp);
|
||||
|
|
@ -590,6 +619,14 @@ fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain:
|
|||
out
|
||||
}
|
||||
|
||||
/// Where a line with no break opportunity before glyph `i` (a word wider than the measure) is
|
||||
/// split: before `i`, or earlier where kinsoku forbids that (no line starts with closing
|
||||
/// punctuation).
|
||||
fn emergency_split(glyphs: &[Glyph], start: usize, i: usize) -> usize {
|
||||
let allowed = |j: usize| matches!((glyphs.get(j.wrapping_sub(1)), glyphs.get(j)), (Some(a), Some(b)) if kinsoku_allows(a.ch, b.ch));
|
||||
(start + 1..=i).rev().find(|&j| allowed(j)).unwrap_or(i)
|
||||
}
|
||||
|
||||
/// Greedy first-fit breaking (single-line composer; also used for paragraphs with tabs).
|
||||
pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec<Break> {
|
||||
let n = glyphs.len();
|
||||
|
|
@ -638,7 +675,7 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F
|
|||
(last_space.map_or(i, |s| s.0), false, false)
|
||||
}
|
||||
Some((p, h)) => (p, h, false),
|
||||
None => (i, false, false),
|
||||
None => (emergency_split(glyphs, start, i), false, false),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
@ -648,7 +685,7 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F
|
|||
}
|
||||
if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break && g.break_after != Some(false) {
|
||||
if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/')
|
||||
|| g.break_after.unwrap_or_else(|| cjk_break_between(g.ch, glyphs[i + 1].ch))
|
||||
|| g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp))
|
||||
{
|
||||
last_ok = Some((i + 1, false));
|
||||
} else if hyph_after[i] && may_hyphenate(hyphens) {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ impl Cache {
|
|||
// Advanced Type sizes super/subscripts.
|
||||
let a = doc.settings.advanced_type;
|
||||
sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].map(|v| v.to_bits() as usize));
|
||||
// Missing glyphs: fallback fonts or the font's box.
|
||||
sig.push(usize::from(doc.settings.glyph_fallback));
|
||||
// Named lists continue from earlier stories.
|
||||
for l in &doc.settings.lists {
|
||||
sig.push(doc.list_start(sid, &l.name) as usize);
|
||||
|
|
@ -240,7 +242,9 @@ impl Cache {
|
|||
}
|
||||
|
||||
fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>) {
|
||||
let mut sig = vec![Arc::as_ptr(story) as usize, story.rev as usize, Arc::as_ptr(&doc.styles) as usize, doc.sections.len()];
|
||||
// The document's font scope: another document's fonts of the same name set it differently.
|
||||
let mut sig =
|
||||
vec![Arc::as_ptr(story) as usize, story.rev as usize, Arc::as_ptr(&doc.styles) as usize, doc.sections.len(), doc.font_scope as usize];
|
||||
let mut keep = Vec::new();
|
||||
let mut spreads = Vec::new();
|
||||
for f in &story.frames {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ use designcraft_doc::{
|
|||
Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions,
|
||||
VerticalJustification, WrapMode, story,
|
||||
};
|
||||
use designcraft_fonts::FontDb;
|
||||
use designcraft_fonts::{FontDb, ScopedFonts};
|
||||
use designcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::breaker::{Break, Spacing};
|
||||
|
|
@ -115,15 +115,29 @@ pub struct PlacedGlyph {
|
|||
}
|
||||
|
||||
impl PlacedGlyph {
|
||||
/// In a vertical frame, the turn that sets this glyph upright (about its em box centre, or
|
||||
/// across the line in a tate-chu-yoko group), applied after drawing it at `x` on `baseline`.
|
||||
/// In a vertical frame, the turn that sets this glyph upright, applied after drawing it at `x`
|
||||
/// on `baseline`: it hangs from its vertical origin ([`designcraft_fonts::FontFace::v_origin`],
|
||||
/// centred across) at `x` on the line's centre, the middle of the font's em box; a tate-chu-yoko
|
||||
/// group sits across that centre.
|
||||
pub fn vertical_xf(&self, baseline: f64) -> Option<designcraft_geom::Affine> {
|
||||
let turn = -std::f64::consts::FRAC_PI_2;
|
||||
// The em box centre above the baseline, in ems.
|
||||
let (top, bottom) = self.face.em_box();
|
||||
let centre = (top + bottom) / 2.0 / self.face.units_per_em();
|
||||
if let Some([along, across, em]) = self.tcy {
|
||||
let c = Point::new(self.x + along, baseline + self.y - em * 0.38);
|
||||
let c = Point::new(self.x + along, baseline + self.y - em * centre);
|
||||
return Some(designcraft_geom::Affine::rotate_about(turn, c) * designcraft_geom::Affine::translate((c.x + across - self.x, 0.0)));
|
||||
}
|
||||
self.upright.then(|| designcraft_geom::Affine::rotate_about(turn, Point::new(self.x + self.adv / 2.0, baseline + self.y - self.adv * 0.38)))
|
||||
if !self.upright {
|
||||
return None;
|
||||
}
|
||||
// Turning about c takes the glyph's vertical origin (half across, `origin` up) to the
|
||||
// centre point at `x`: c is where the two points' perpendicular bisector meets the turn.
|
||||
let half = self.face.advance(self.gid) / 2.0 * self.sx;
|
||||
let origin = self.face.v_origin(self.gid) * self.sy;
|
||||
let mid = centre * self.face.units_per_em() * self.sy;
|
||||
let c = Point::new(self.x + (origin - mid + half) / 2.0, baseline + self.y + (half - mid - origin) / 2.0);
|
||||
Some(designcraft_geom::Affine::rotate_about(turn, c))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -263,6 +277,9 @@ pub struct FrameSpec {
|
|||
/// Text area (inner space, after inset).
|
||||
pub area: Rect,
|
||||
pub opts: TextFrameOptions,
|
||||
/// Lines run top to bottom and follow each other right to left (the story is vertical); the
|
||||
/// area is the turned box.
|
||||
pub vertical: bool,
|
||||
pub exclusions: Vec<Exclusion>,
|
||||
pub page_name: Option<String>,
|
||||
/// Absolute document page the frame is on (None on parent pages).
|
||||
|
|
@ -384,7 +401,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
|
|||
None => (item.text_area(), tf.options.clone()),
|
||||
};
|
||||
// Vertical type: composed in the turned box; wraps and page rects turned with it.
|
||||
let (area, exclusions, grid, page_rect) = if tf.options.vertical && tf.options.path.is_none() {
|
||||
let vertical = doc.frame_vertical(item);
|
||||
let (area, exclusions, grid, page_rect) = if vertical {
|
||||
let v = designcraft_doc::vertical_text_xf(area).inverse();
|
||||
let ex = exclusions.into_iter().map(|e| Exclusion { rect: v.transform_rect_bbox(e.rect), ..e }).collect();
|
||||
let pr = page_rect.map(|(a, b)| (v.transform_rect_bbox(a), v.transform_rect_bbox(b)));
|
||||
|
|
@ -396,6 +414,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
|
|||
id: fid,
|
||||
area,
|
||||
opts,
|
||||
vertical,
|
||||
exclusions: if tf.options.path.is_some() { vec![] } else { exclusions },
|
||||
page_name,
|
||||
page,
|
||||
|
|
@ -424,6 +443,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
|||
}
|
||||
|
||||
fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &ComposeOptions, db: &FontDb) -> ComposedStory {
|
||||
let db = &db.scoped(doc.font_scope);
|
||||
let mut out = ComposedStory { story: story.id, rev: story.rev, text_len: story.text.len(), ..Default::default() };
|
||||
let mut styles_tab: Vec<RunStyle> = Vec::new();
|
||||
let mut missing_fonts: HashMap<String, bool> = HashMap::new();
|
||||
|
|
@ -431,7 +451,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
|||
.iter()
|
||||
.map(|f| {
|
||||
let mut columns = f.columns();
|
||||
if story.direction == designcraft_doc::TextDirection::RightToLeft && !f.opts.vertical {
|
||||
if story.direction == designcraft_doc::TextDirection::RightToLeft && !f.vertical {
|
||||
columns.reverse();
|
||||
}
|
||||
columns
|
||||
|
|
@ -440,7 +460,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
|||
out.frames = frames
|
||||
.iter()
|
||||
.zip(&cols)
|
||||
.map(|(f, columns)| FrameText { frame: f.id, vertical: f.opts.vertical, columns: columns.clone(), ..Default::default() })
|
||||
.map(|(f, columns)| FrameText { frame: f.id, vertical: f.vertical, columns: columns.clone(), ..Default::default() })
|
||||
.collect();
|
||||
let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, last_reference: 0.0, pending: 0.0 };
|
||||
let para_ranges = story.para_ranges();
|
||||
|
|
@ -533,7 +553,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
|||
section_marker: None,
|
||||
vars: var_values,
|
||||
hidden_conditions: doc.conditions.iter().filter(|c| !c.visible).map(|c| c.name.clone()).collect(),
|
||||
vertical: cur_frame.is_some_and(|f| f.opts.vertical),
|
||||
vertical: cur_frame.is_some_and(|f| f.vertical),
|
||||
..Default::default()
|
||||
};
|
||||
if !story.endnotes.is_empty() {
|
||||
|
|
@ -564,7 +584,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
|||
}
|
||||
let sub_objects = sub.objects.clone();
|
||||
let mut table = StyleTable { styles: &mut styles_tab, missing: &mut missing_fonts };
|
||||
let env = shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type };
|
||||
let env = shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type, glyph_fallback: doc.settings.glyph_fallback };
|
||||
let mut sp = shape::shape_para(
|
||||
db,
|
||||
&doc.styles,
|
||||
|
|
@ -634,28 +654,30 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
|||
);
|
||||
}
|
||||
}
|
||||
// List labels take the default super/subscript settings.
|
||||
let label_env = shape::TypeEnv { adv: Default::default(), ..env };
|
||||
match pp.list_type {
|
||||
designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => {
|
||||
// A named list: carries on past other paragraphs (and from earlier stories).
|
||||
let n = named_numbers.get(pi).copied().flatten().unwrap_or(1);
|
||||
let label = format!("{}.{}", pp.number_style.format(n), pp.list_separator);
|
||||
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, &pp, &mut table);
|
||||
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table);
|
||||
}
|
||||
designcraft_doc::ListType::Numbers => {
|
||||
list_counter = pp.start_at.map_or(list_counter + 1, |s| s.max(1));
|
||||
let label = format!("{}.{}", pp.number_style.format(list_counter), pp.list_separator);
|
||||
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, &pp, &mut table);
|
||||
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table);
|
||||
}
|
||||
designcraft_doc::ListType::Bullets => {
|
||||
let label = format!("{}{}", pp.bullet_char, pp.list_separator);
|
||||
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, &pp, &mut table);
|
||||
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table);
|
||||
}
|
||||
designcraft_doc::ListType::None => list_counter = 0,
|
||||
}
|
||||
if pi == 0
|
||||
&& let Some(label) = &opts.label
|
||||
{
|
||||
prepend_label(db, &mut sp.glyphs, label, prange.start, &base_chars, &pp, &mut table);
|
||||
prepend_label(db, &mut sp.glyphs, label, prange.start, &base_chars, label_env, &mut table);
|
||||
}
|
||||
let mut glyphs = sp.glyphs;
|
||||
let bidi_text = bidi::paragraph_text(&mut glyphs, &story.text);
|
||||
|
|
@ -824,7 +846,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
|||
let last = k + 1 == breaks.len();
|
||||
let (mut placed, end_x, ratio) =
|
||||
layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page, &bidi_info);
|
||||
ruby::annotate(&styles_tab, &mut placed);
|
||||
ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback);
|
||||
let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) };
|
||||
let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end);
|
||||
let range_start = if line_no == 0 { prange.start } else { range_start };
|
||||
|
|
@ -1128,6 +1150,7 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
|
|||
ragged_stretch: base_size * 2.0,
|
||||
hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone },
|
||||
optical: pp.optical_margin,
|
||||
korean_char_breaks: pp.korean_char_breaks,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1146,7 +1169,8 @@ fn apply_desired_spacing(glyphs: &mut [Glyph], pp: &ParaProps) {
|
|||
if let Some(next) = right.first() {
|
||||
let a = g.ch;
|
||||
let b = next.ch;
|
||||
let cjk = upright_in_vertical(a) || upright_in_vertical(b);
|
||||
// A kinsoku set rules where CJK text may break; Korean still breaks at spaces.
|
||||
let cjk = breaker::cjk_pair(g, next, pp.korean_char_breaks);
|
||||
if g.break_after != Some(false) {
|
||||
if let Some(set) = &pp.kinsoku {
|
||||
if !set.allows(a, b) {
|
||||
|
|
@ -1406,13 +1430,12 @@ fn line_metrics(
|
|||
s: usize,
|
||||
base_leading: f64,
|
||||
base_size: f64,
|
||||
db: &FontDb,
|
||||
db: &ScopedFonts<'_>,
|
||||
base: &designcraft_doc::CharProps,
|
||||
) -> (f64, f64, f64) {
|
||||
let _ = db;
|
||||
let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line };
|
||||
if src.is_empty() {
|
||||
let face = FontDb::global().face(&base.font_family, &base.font_style);
|
||||
let face = db.face(&base.font_family, &base.font_style);
|
||||
let k = base_size / face.upem;
|
||||
return (face.ascent * k, face.descent * k, base_leading);
|
||||
}
|
||||
|
|
@ -1958,39 +1981,26 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph {
|
|||
byte: g.byte,
|
||||
len: g.len,
|
||||
visible,
|
||||
upright: upright_in_vertical(g.ch),
|
||||
upright: g.upright,
|
||||
tcy: g.tcy,
|
||||
rtl: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn prepend_label(
|
||||
db: &FontDb,
|
||||
db: &ScopedFonts<'_>,
|
||||
glyphs: &mut Vec<Glyph>,
|
||||
label: &str,
|
||||
at: usize,
|
||||
base: &designcraft_doc::CharProps,
|
||||
pp: &ParaProps,
|
||||
env: shape::TypeEnv,
|
||||
table: &mut StyleTable<'_>,
|
||||
) {
|
||||
// Shape the label as a tiny standalone story so it uses the paragraph's base character style.
|
||||
let mut tmp = Story::new(StoryId(0));
|
||||
tmp.insert(0, label);
|
||||
let styles = designcraft_doc::Styles::default();
|
||||
let shaped = shape::shape_para(
|
||||
db,
|
||||
&styles,
|
||||
&tmp,
|
||||
0,
|
||||
0..label.len(),
|
||||
base,
|
||||
shape::TypeEnv { auto_leading: pp.auto_leading, adv: Default::default() },
|
||||
&SubstCtx::default(),
|
||||
table,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, env, &SubstCtx::default(), table, &[], &[], &[]);
|
||||
let mut pre: Vec<Glyph> = shaped
|
||||
.glyphs
|
||||
.into_iter()
|
||||
|
|
@ -2008,9 +2018,11 @@ type LimitsKey = (usize, usize, usize, bool);
|
|||
|
||||
/// Mark glyphs after which a hyphen may be inserted (dictionary/pattern points within the
|
||||
/// paragraph's limits; words with discretionary hyphens break only there).
|
||||
/// Whether a language uses the English hyphenation and spelling dictionaries.
|
||||
/// Whether a language (a name or locale code, see [`designcraft_doc::language_tag`]) uses the
|
||||
/// English hyphenation and spelling dictionaries.
|
||||
pub fn is_english(language: &str) -> bool {
|
||||
language.starts_with("English")
|
||||
// InDesign's English names first: composition asks this for every run.
|
||||
language.starts_with("English") || designcraft_doc::language_tag(language).is_some_and(|t| designcraft_doc::language_subtag(t) == "en")
|
||||
}
|
||||
|
||||
/// Byte ranges of paragraph `prange` set in a language other than English (no English
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ impl Notes {
|
|||
id: ItemId(0),
|
||||
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
|
||||
opts: TextFrameOptions { first_baseline: o.first_baseline, first_baseline_min: o.first_baseline_min, ..Default::default() },
|
||||
vertical: false,
|
||||
exclusions: vec![],
|
||||
page_name: f.page_name.clone(),
|
||||
page: f.page,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Ruby and kenten: small glyphs set over laid-out text (to its right in vertical frames, where
|
||||
//! the frame's turn carries "above" there). They don't change line breaks or leading.
|
||||
|
||||
use designcraft_fonts::ScopedFonts;
|
||||
use designcraft_geom::Shape as _;
|
||||
|
||||
use crate::{PlacedGlyph, RunStyle, upright_in_vertical};
|
||||
|
|
@ -39,18 +40,21 @@ fn set(base: &PlacedGlyph, text: &str, size: f64, base_size: f64) -> (Vec<Placed
|
|||
(out, x)
|
||||
}
|
||||
|
||||
/// A sesame dot (or a bullet, when the font has none) centred over `b`.
|
||||
fn kenten(b: &PlacedGlyph, size: f64, character: &str) -> Vec<PlacedGlyph> {
|
||||
/// The emphasis `character` (a sesame dot when empty) centred over `b`, in `b`'s font. A
|
||||
/// character the font lacks is its missing-glyph box, unless `fallback` (fallback fonts draw
|
||||
/// missing glyphs): then a fallback font draws it, or failing that a bullet.
|
||||
fn kenten(db: &ScopedFonts<'_>, b: &PlacedGlyph, size: f64, character: &str, fallback: bool) -> Vec<PlacedGlyph> {
|
||||
let character = if character.is_empty() { "\u{FE45}" } else { character };
|
||||
let mut base = b.clone();
|
||||
if let Some(c) = character.chars().next()
|
||||
if fallback
|
||||
&& let Some(c) = character.chars().next()
|
||||
&& !base.face.covers(c)
|
||||
&& let Some(face) = designcraft_fonts::FontDb::global().fallback_for(c, base.face.id())
|
||||
&& let Some(face) = db.fallback_for(c, base.face.id(), None)
|
||||
{
|
||||
base.face = designcraft_fonts::FaceRef::of(&face);
|
||||
}
|
||||
let (mut dot, mut w) = set(&base, character, size * SCALE, size);
|
||||
if dot.first().is_some_and(|d| d.gid == 0) {
|
||||
if fallback && dot.first().is_some_and(|d| d.gid == 0) {
|
||||
(dot, w) = set(b, "\u{2022}", size * SCALE, size);
|
||||
}
|
||||
// Centre the mark's ink (its advance may hold it off centre) and sit it just over the text.
|
||||
|
|
@ -67,8 +71,9 @@ fn kenten(b: &PlacedGlyph, size: f64, character: &str) -> Vec<PlacedGlyph> {
|
|||
dot
|
||||
}
|
||||
|
||||
/// Add the ruby and kenten of a laid-out line's glyphs.
|
||||
pub(crate) fn annotate(styles: &[RunStyle], line: &mut Vec<PlacedGlyph>) {
|
||||
/// Add the ruby and kenten of a laid-out line's glyphs. `glyph_fallback`: the document draws
|
||||
/// missing glyphs from fallback fonts (a missing font's substitute always does).
|
||||
pub(crate) fn annotate(db: &ScopedFonts<'_>, styles: &[RunStyle], line: &mut Vec<PlacedGlyph>, glyph_fallback: bool) {
|
||||
let style = |g: &PlacedGlyph| styles.get(g.style as usize);
|
||||
if !line.iter().any(|g| style(g).is_some_and(|s| s.ruby.is_some() || s.kenten)) {
|
||||
return;
|
||||
|
|
@ -82,7 +87,7 @@ pub(crate) fn annotate(styles: &[RunStyle], line: &mut Vec<PlacedGlyph>) {
|
|||
continue;
|
||||
};
|
||||
if st.kenten && g.adv > 0.0 {
|
||||
extra.extend(kenten(g, st.size, &st.kenten_character));
|
||||
extra.extend(kenten(db, g, st.size, &st.kenten_character, glyph_fallback || st.missing_font));
|
||||
}
|
||||
let Some(text) = &st.ruby else {
|
||||
i += 1;
|
||||
|
|
@ -114,7 +119,7 @@ pub(crate) fn annotate(styles: &[RunStyle], line: &mut Vec<PlacedGlyph>) {
|
|||
extra.extend(glyphs);
|
||||
// Kenten on the rest of the group.
|
||||
for b in line[i + 1..j].iter().filter(|b| st.kenten && b.len > 0 && b.adv > 0.0) {
|
||||
extra.extend(kenten(b, st.size, &st.kenten_character));
|
||||
extra.extend(kenten(db, b, st.size, &st.kenten_character, glyph_fallback || st.missing_font));
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::collections::HashMap;
|
|||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use designcraft_doc::{Capitalization, CharProps, Kerning, Leading, Position, Story, Styles, story};
|
||||
use designcraft_fonts::{FaceRef, Feature, FontDb, FontFace, ShapedGlyph, feature};
|
||||
use designcraft_fonts::{FaceRef, Feature, FontFace, ScopedFonts, ShapedGlyph, feature};
|
||||
|
||||
use crate::RunStyle;
|
||||
|
||||
|
|
@ -59,6 +59,11 @@ pub struct Glyph {
|
|||
pub allow_kashidas: bool,
|
||||
pub safe_tatweel_before: bool,
|
||||
pub shaping_rtl: bool,
|
||||
/// The character's language as a BCP 47 tag ([`designcraft_doc::language_tag`]).
|
||||
pub lang: Option<&'static str>,
|
||||
/// Upright in a vertical frame: shaped top to bottom (`adv` is its vertical advance) and hung
|
||||
/// from its vertical origin on the line's centre (see [`crate::PlacedGlyph::vertical_xf`]).
|
||||
pub upright: bool,
|
||||
}
|
||||
|
||||
impl Glyph {
|
||||
|
|
@ -97,7 +102,8 @@ pub struct SubstCtx {
|
|||
pub objects: HashMap<usize, ObjectSpec>,
|
||||
/// Conditions currently hidden (conditional text with only these isn't shown).
|
||||
pub hidden_conditions: Vec<String>,
|
||||
/// Set in a Vertical Type frame: vertical glyph forms (`vert`, `vrt2`).
|
||||
/// Set in a Vertical Type frame: upright runs are shaped top to bottom (vertical forms and
|
||||
/// metrics), the rest horizontally to be turned as a whole.
|
||||
pub vertical: bool,
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +131,7 @@ pub(crate) struct StyleTable<'a> {
|
|||
}
|
||||
|
||||
impl StyleTable<'_> {
|
||||
fn intern(&mut self, p: &CharProps) -> u32 {
|
||||
fn intern(&mut self, db: &ScopedFonts<'_>, p: &CharProps) -> u32 {
|
||||
let rs = RunStyle {
|
||||
fill: p.fill.clone(),
|
||||
fill_tint: p.fill_tint,
|
||||
|
|
@ -148,10 +154,7 @@ impl StyleTable<'_> {
|
|||
},
|
||||
skew: p.skew,
|
||||
size: p.size,
|
||||
missing_font: *self
|
||||
.missing
|
||||
.entry(p.font_family.clone())
|
||||
.or_insert_with(|| !p.font_family.is_empty() && !designcraft_fonts::FontDb::global().has_family(&p.font_family)),
|
||||
missing_font: *self.missing.entry(p.font_family.clone()).or_insert_with(|| !p.font_family.is_empty() && !db.has_family(&p.font_family)),
|
||||
custom_tracking: p.tracking.abs() > 1e-9 || matches!(p.kerning, designcraft_doc::Kerning::Manual(_)),
|
||||
condition: p.conditions.first().cloned(),
|
||||
inserted: p.change == designcraft_doc::ChangeMark::Inserted,
|
||||
|
|
@ -166,11 +169,18 @@ impl StyleTable<'_> {
|
|||
self.styles.push(rs);
|
||||
(self.styles.len() - 1) as u32
|
||||
}
|
||||
|
||||
/// `env` for text in run style `style`: a missing font's substitute draws the characters it
|
||||
/// lacks from fallback fonts whatever the document's setting.
|
||||
fn env_for(&self, env: TypeEnv, style: u32) -> TypeEnv {
|
||||
let missing = self.styles.get(style as usize).is_some_and(|s| s.missing_font);
|
||||
TypeEnv { glyph_fallback: env.glyph_fallback || missing, ..env }
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve and shape one paragraph.
|
||||
pub(crate) fn shape_para(
|
||||
db: &FontDb,
|
||||
db: &ScopedFonts<'_>,
|
||||
styles: &Styles,
|
||||
story: &Story,
|
||||
pi: usize,
|
||||
|
|
@ -231,18 +241,20 @@ pub(crate) fn shape_para(
|
|||
for (offset, c) in story.text.get(a..b).unwrap_or("").char_indices() {
|
||||
let entry = font.entry(c);
|
||||
if entry != selected && a + offset > start {
|
||||
resolved.push((start, a + offset, composite_props(&props, selected), fmt, para_chars.clone()));
|
||||
resolved.push((start, a + offset, composite_props(db, &props, selected), fmt, para_chars.clone()));
|
||||
start = a + offset;
|
||||
}
|
||||
selected = entry;
|
||||
}
|
||||
resolved.push((start, b, composite_props(&props, selected), fmt, para_chars.clone()));
|
||||
resolved.push((start, b, composite_props(db, &props, selected), fmt, para_chars.clone()));
|
||||
} else {
|
||||
resolved.push((a, b, props, fmt, para_chars.clone()));
|
||||
}
|
||||
}
|
||||
for (a, b, props, fmt, resolved_base) in resolved {
|
||||
let style = table.intern(&props);
|
||||
let style = table.intern(db, &props);
|
||||
// A missing font's substitute stands in for the whole font: fallback fonts help it.
|
||||
let auto_leading = table.env_for(auto_leading, style);
|
||||
let deleted = props.change == designcraft_doc::ChangeMark::Deleted;
|
||||
if deleted || (!props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c))) {
|
||||
// Hidden conditional text: zero-width, unbreakable, undrawn place-holders keep every
|
||||
|
|
@ -272,11 +284,13 @@ pub(crate) fn shape_para(
|
|||
}
|
||||
rf.over.position = Some(sub.note_position);
|
||||
let rprops = styles.resolve_char(&resolved_base, &rf);
|
||||
let rstyle = table.intern(&rprops);
|
||||
let rstyle = table.intern(db, &rprops);
|
||||
let renv = table.env_for(auto_leading, rstyle);
|
||||
let mut ef = fmt.clone();
|
||||
ef.over.position = Some(designcraft_doc::Position::Superscript);
|
||||
let eprops = styles.resolve_char(&resolved_base, &ef);
|
||||
let estyle = table.intern(&eprops);
|
||||
let estyle = table.intern(db, &eprops);
|
||||
let eenv = table.env_for(auto_leading, estyle);
|
||||
let mut k = a;
|
||||
for (i, m) in story.text[a..b].match_indices(is_ref) {
|
||||
let i = a + i;
|
||||
|
|
@ -285,9 +299,9 @@ pub(crate) fn shape_para(
|
|||
}
|
||||
let e = i + m.len();
|
||||
if m.starts_with(designcraft_doc::ENDNOTE_REF) {
|
||||
shape_run(db, &story.text, i..e, &eprops, auto_leading, estyle, sub, &mut glyphs);
|
||||
shape_run(db, &story.text, i..e, &eprops, eenv, estyle, sub, &mut glyphs);
|
||||
} else {
|
||||
shape_run(db, &story.text, i..e, &rprops, auto_leading, rstyle, sub, &mut glyphs);
|
||||
shape_run(db, &story.text, i..e, &rprops, renv, rstyle, sub, &mut glyphs);
|
||||
}
|
||||
k = e;
|
||||
}
|
||||
|
|
@ -389,7 +403,7 @@ fn is_mark(c: char) -> bool {
|
|||
unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM || matches!(c, '\u{200C}' | '\u{200D}')
|
||||
}
|
||||
|
||||
fn composite_props(base: &CharProps, entry: Option<&designcraft_doc::cjk::CompositeFontEntry>) -> CharProps {
|
||||
fn composite_props(db: &ScopedFonts<'_>, base: &CharProps, entry: Option<&designcraft_doc::cjk::CompositeFontEntry>) -> CharProps {
|
||||
let mut p = base.clone();
|
||||
if let Some(e) = entry {
|
||||
p.font_family.clone_from(&e.family);
|
||||
|
|
@ -399,7 +413,7 @@ fn composite_props(base: &CharProps, entry: Option<&designcraft_doc::cjk::Compos
|
|||
p.v_scale *= e.vertical_scale.clamp(0.01, 100.0);
|
||||
p.baseline_shift += base.size * e.baseline_shift;
|
||||
if e.scale_option {
|
||||
let face = FontDb::global().face(&p.font_family, &p.font_style);
|
||||
let face = db.face(&p.font_family, &p.font_style);
|
||||
let (asc, desc) = face.vertical_metrics();
|
||||
let center = (asc - desc) / (2.0 * (asc + desc).max(1e-9));
|
||||
p.baseline_shift += (base.size * base.v_scale - p.size * p.v_scale) * center;
|
||||
|
|
@ -409,7 +423,16 @@ fn composite_props(base: &CharProps, entry: Option<&designcraft_doc::cjk::Compos
|
|||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shape_run(db: &FontDb, text: &str, range: std::ops::Range<usize>, p: &CharProps, env: TypeEnv, style: u32, sub: &SubstCtx, out: &mut Vec<Glyph>) {
|
||||
fn shape_run(
|
||||
db: &ScopedFonts<'_>,
|
||||
text: &str,
|
||||
range: std::ops::Range<usize>,
|
||||
p: &CharProps,
|
||||
env: TypeEnv,
|
||||
style: u32,
|
||||
sub: &SubstCtx,
|
||||
out: &mut Vec<Glyph>,
|
||||
) {
|
||||
let start = out.len();
|
||||
shape_run_raw(db, text, range, p, env, style, sub, out);
|
||||
let run = &mut out[start..];
|
||||
|
|
@ -456,7 +479,7 @@ fn shape_run(db: &FontDb, text: &str, range: std::ops::Range<usize>, p: &CharPro
|
|||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shape_run_raw(
|
||||
db: &FontDb,
|
||||
db: &ScopedFonts<'_>,
|
||||
text: &str,
|
||||
range: std::ops::Range<usize>,
|
||||
p: &CharProps,
|
||||
|
|
@ -466,6 +489,12 @@ fn shape_run_raw(
|
|||
out: &mut Vec<Glyph>,
|
||||
) {
|
||||
let primary = db.face(&p.font_family, &p.font_style);
|
||||
// The language picks the fallback for CJK characters the primary font lacks. Without
|
||||
// fallback, the primary font sets them: the shaper gives its .notdef (glyph 0).
|
||||
let lang = designcraft_doc::language_tag(&p.language);
|
||||
let fallback = |c: char| {
|
||||
if auto_leading.glyph_fallback { db.fallback_for(c, primary.id(), lang).unwrap_or_else(|| primary.clone()) } else { primary.clone() }
|
||||
};
|
||||
let strong = |c| match unicode_bidi::bidi_class(c) {
|
||||
unicode_bidi::BidiClass::AL => Some(true),
|
||||
unicode_bidi::BidiClass::L | unicode_bidi::BidiClass::R => Some(false),
|
||||
|
|
@ -501,7 +530,7 @@ fn shape_run_raw(
|
|||
if d != c {
|
||||
flush(seg_start, i, &seg_face, out);
|
||||
seg_start = i + c.len_utf8();
|
||||
let face = if primary.covers(d) { primary.clone() } else { db.fallback_for(d, primary.id()).unwrap_or_else(|| primary.clone()) };
|
||||
let face = if primary.covers(d) { primary.clone() } else { fallback(d) };
|
||||
shape_segment(db, text, i..i + c.len_utf8(), Some(d.encode_utf8(&mut [0; 4])), p, &face, auto_leading, style, out, sub.vertical);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -599,11 +628,7 @@ fn shape_run_raw(
|
|||
continue;
|
||||
}
|
||||
let covered = c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN || primary.covers(c);
|
||||
let face = if covered || is_mark(c) {
|
||||
if is_mark(c) { seg_face.clone() } else { primary.clone() }
|
||||
} else {
|
||||
db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone())
|
||||
};
|
||||
let face = if covered || is_mark(c) { if is_mark(c) { seg_face.clone() } else { primary.clone() } } else { fallback(c) };
|
||||
if face.id() != seg_face.id() {
|
||||
flush(seg_start, i, &seg_face, out);
|
||||
seg_start = i;
|
||||
|
|
@ -614,10 +639,11 @@ fn shape_run_raw(
|
|||
}
|
||||
|
||||
type WordMap = HashMap<Box<str>, Arc<[ShapedGlyph]>>;
|
||||
type WordKey = (u32, bool, String);
|
||||
type WordKey = (u32, bool, bool, String, Option<&'static str>);
|
||||
|
||||
/// Shaped words (font units) by (face, caps, features), then text. Lookups take read locks and
|
||||
/// misses are shaped outside any lock, so parallel composition doesn't serialize here.
|
||||
/// Shaped words (font units) by (face, vertical, caps, features, language), then text. Lookups
|
||||
/// take read locks and misses are shaped outside any lock, so parallel composition doesn't
|
||||
/// serialize here.
|
||||
static WORD_CACHE: RwLock<Vec<(WordKey, Arc<RwLock<WordMap>>)>> = RwLock::new(Vec::new());
|
||||
const WORD_CACHE_MAX: usize = 50_000;
|
||||
|
||||
|
|
@ -640,12 +666,28 @@ fn word_map(key: WordKey) -> Arc<RwLock<WordMap>> {
|
|||
/// Shape `src` word by word through a cache (like a browser's word cache): the text is split
|
||||
/// after each U+0020 so repeated words are shaped once. Shaping does not cross word spaces in the
|
||||
/// scripts we lay out, and clusters stay byte offsets into `src`.
|
||||
fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], caps: bool, context: designcraft_fonts::ShapeContext<'_>) -> Vec<ShapedGlyph> {
|
||||
fn shape_cached(
|
||||
face: &FontFace,
|
||||
src: &str,
|
||||
feats: &[Feature],
|
||||
lang: Option<&'static str>,
|
||||
caps: bool,
|
||||
vertical: bool,
|
||||
(before, after): (&str, &str),
|
||||
) -> Vec<ShapedGlyph> {
|
||||
use designcraft_fonts::ShapeContext;
|
||||
let map = |c: char| if caps { c.to_uppercase().next().unwrap_or(c) } else { c };
|
||||
let shape = |s: &str, context: ShapeContext<'_>| {
|
||||
if vertical {
|
||||
designcraft_fonts::shape_vertical(face, s, feats, map, context)
|
||||
} else {
|
||||
designcraft_fonts::shape_with_context(face, s, feats, map, context)
|
||||
}
|
||||
};
|
||||
if src.len() < 2 || !src.contains(' ') || src.chars().any(designcraft_fonts::is_rtl) {
|
||||
return designcraft_fonts::shape_with_context(face, src, feats, map, context);
|
||||
return shape(src, ShapeContext { before, after, language: lang });
|
||||
}
|
||||
let words = word_map((face.id(), caps, format!("{feats:?}/{}", context.language)));
|
||||
let words = word_map((face.id(), vertical, caps, format!("{feats:?}"), lang));
|
||||
let pieces: Vec<&str> = src.split_inclusive(' ').collect();
|
||||
let mut found: Vec<Option<Arc<[ShapedGlyph]>>> = {
|
||||
let r = words.read().unwrap_or_else(|e| e.into_inner());
|
||||
|
|
@ -654,14 +696,7 @@ fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], caps: bool, conte
|
|||
let mut fresh: Vec<(Box<str>, Arc<[ShapedGlyph]>)> = Vec::new();
|
||||
for (k, f) in found.iter_mut().enumerate() {
|
||||
if f.is_none() {
|
||||
let g: Arc<[ShapedGlyph]> = designcraft_fonts::shape_with_context(
|
||||
face,
|
||||
pieces[k],
|
||||
feats,
|
||||
map,
|
||||
designcraft_fonts::ShapeContext { language: context.language, ..Default::default() },
|
||||
)
|
||||
.into();
|
||||
let g: Arc<[ShapedGlyph]> = shape(pieces[k], ShapeContext { language: lang, ..Default::default() }).into();
|
||||
fresh.push((pieces[k].into(), g.clone()));
|
||||
*f = Some(g);
|
||||
}
|
||||
|
|
@ -699,6 +734,8 @@ fn metrics(face: &FontFace, p: &CharProps, auto_leading: TypeEnv) -> (f64, f64,
|
|||
pub struct TypeEnv {
|
||||
pub auto_leading: f64,
|
||||
pub adv: designcraft_doc::AdvancedType,
|
||||
/// Characters the font lacks are drawn from fallback fonts (else as its .notdef glyph).
|
||||
pub glyph_fallback: bool,
|
||||
}
|
||||
|
||||
/// Synthesised super/subscript: size and shift from Advanced Type (percent of the font size).
|
||||
|
|
@ -748,12 +785,14 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
|
|||
allow_kashidas: p.allow_kashidas,
|
||||
safe_tatweel_before: false,
|
||||
shaping_rtl: false,
|
||||
lang: designcraft_doc::language_tag(&p.language),
|
||||
upright: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shape_segment(
|
||||
db: &FontDb,
|
||||
db: &ScopedFonts<'_>,
|
||||
text: &str,
|
||||
range: std::ops::Range<usize>,
|
||||
replacement: Option<&str>,
|
||||
|
|
@ -771,24 +810,37 @@ fn shape_segment(
|
|||
let manual = if let Kerning::Manual(v) = p.kerning { v / 1000.0 * p.size } else { 0.0 };
|
||||
let src = replacement.unwrap_or(&text[range.clone()]);
|
||||
let caps = p.capitalization == Capitalization::AllCaps;
|
||||
let mut feats = features_for(p);
|
||||
let feats = features_for(p);
|
||||
// Upright in vertical text, Horizontal Kana takes the vertical kana alternates instead.
|
||||
let mut upright_feats = feats.clone();
|
||||
if vertical && designcraft_doc::otf::is_on(&p.otf_features, "hkna") {
|
||||
feats.extend(feature("-hkna"));
|
||||
feats.extend(feature("vkna"));
|
||||
}
|
||||
if vertical {
|
||||
feats.extend(["vert", "vrt2"].iter().filter_map(|t| designcraft_fonts::feature(t)));
|
||||
upright_feats.extend(feature("-hkna"));
|
||||
upright_feats.extend(feature("vkna"));
|
||||
}
|
||||
let space = face.advance(face.glyph_for(' ')) * k * hs;
|
||||
let context = designcraft_fonts::ShapeContext {
|
||||
before: if replacement.is_none() { text.get(..range.start).unwrap_or("") } else { "" },
|
||||
after: if replacement.is_none() { text.get(range.end..).unwrap_or("") } else { "" },
|
||||
language: &p.language,
|
||||
};
|
||||
let shaped: Vec<ShapedGlyph> = shape_cached(face, src, &feats, caps, context);
|
||||
// The language picks the font's localized forms (`locl`); the text around the run lets
|
||||
// letters join across a change of style.
|
||||
let lang = designcraft_doc::language_tag(&p.language);
|
||||
// Vertical frames: upright runs top to bottom, the rest (turned as a whole) horizontally.
|
||||
// Tate-chu-yoko is set across the line, so it stays horizontal.
|
||||
let mut shaped: Vec<ShapedGlyph> = Vec::new();
|
||||
let mut upright: Vec<bool> = Vec::new();
|
||||
for (r, up) in upright_runs(src, vertical && !p.tate_chu_yoko) {
|
||||
let around = match replacement {
|
||||
None => (text.get(..range.start + r.start).unwrap_or(""), text.get(range.start + r.end..).unwrap_or("")),
|
||||
Some(_) => ("", ""),
|
||||
};
|
||||
let g = shape_cached(face, &src[r.clone()], if up { &upright_feats } else { &feats }, lang, caps, up, around);
|
||||
upright.extend(std::iter::repeat_n(up, g.len()));
|
||||
shaped.extend(g.into_iter().map(|g| ShapedGlyph { cluster: g.cluster + r.start, ..g }));
|
||||
}
|
||||
// Upright glyphs fill the ideographic em box across the line.
|
||||
let (em_top, em_bottom) = face.em_box();
|
||||
let k_v = k * p.v_scale;
|
||||
let n = shaped.len();
|
||||
let fref = FaceRef::of(face);
|
||||
for (gi, sg) in shaped.iter().enumerate() {
|
||||
let up = upright.get(gi).copied().unwrap_or(false);
|
||||
let (byte, len, ch) = if replacement.is_some() {
|
||||
(range.start, if gi == 0 { range.len() } else { 0 }, text[range.start..].chars().next().unwrap_or(' '))
|
||||
} else {
|
||||
|
|
@ -797,7 +849,14 @@ fn shape_segment(
|
|||
(cl, end.saturating_sub(cl), text[cl..].chars().next().unwrap_or(' '))
|
||||
};
|
||||
let last_in_cluster = gi + 1 == n || shaped[gi + 1].cluster != sg.cluster;
|
||||
let mut adv = sg.x_advance as f64 * k * hs;
|
||||
// Upright: down the line is the shaper's -y, across it (text space -y) its x.
|
||||
let mut adv = if up { -(sg.y_advance as f64) * k_v } else { sg.x_advance as f64 * k * hs };
|
||||
let (dx, dy) = if up {
|
||||
(-(sg.y_offset as f64) * k_v, -(sg.x_offset as f64) * k * hs)
|
||||
} else {
|
||||
(sg.x_offset as f64 * k * hs, -(sg.y_offset as f64) * k * p.v_scale)
|
||||
};
|
||||
let (ascent, descent) = if up { (em_top * k_v, -em_bottom * k_v) } else { (ascent, descent) };
|
||||
if ch == SOFT_HYPHEN {
|
||||
adv = 0.0;
|
||||
} else if last_in_cluster {
|
||||
|
|
@ -815,8 +874,8 @@ fn shape_segment(
|
|||
len: if first_in_cluster || replacement.is_some() { len } else { 0 },
|
||||
ch,
|
||||
adv,
|
||||
dx: sg.x_offset as f64 * k * hs + mark_x,
|
||||
dy: -(sg.y_offset as f64) * k * p.v_scale - mark_y,
|
||||
dx: dx + mark_x,
|
||||
dy: dy - mark_y,
|
||||
sx: k * hs,
|
||||
sy: k * p.v_scale,
|
||||
shift,
|
||||
|
|
@ -848,10 +907,27 @@ fn shape_segment(
|
|||
allow_kashidas: p.allow_kashidas,
|
||||
safe_tatweel_before: sg.safe_tatweel_before,
|
||||
shaping_rtl: sg.rtl,
|
||||
lang,
|
||||
upright: up,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `src` cut into runs set upright in vertical text (CJK) and runs that turn with the line, as
|
||||
/// (byte range, upright); combining marks stay with the run they follow. One horizontal run when
|
||||
/// `vertical` is false.
|
||||
fn upright_runs(src: &str, vertical: bool) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
|
||||
for (i, c) in src.char_indices() {
|
||||
let up = vertical && crate::upright_in_vertical(c);
|
||||
match runs.last_mut() {
|
||||
Some((r, u)) if *u == up || is_mark(c) => r.end = i + c.len_utf8(),
|
||||
_ => runs.push((i..i + c.len_utf8(), up)),
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// A hyphen glyph in the face/size of `g`, placed after it (zero source length).
|
||||
pub(crate) fn hyphen_after(g: &Glyph) -> Glyph {
|
||||
let gid = designcraft_fonts::first_glyph(&g.face, &['-', '\u{2010}']);
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ fn compose_cell(doc: &Document, story: &Story, w: f64, opts: &ComposeOptions, le
|
|||
id: ItemId(0),
|
||||
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
|
||||
opts: TextFrameOptions::default(),
|
||||
vertical: false,
|
||||
exclusions: vec![],
|
||||
page_name: opts.page_name.clone(),
|
||||
page: opts.page,
|
||||
|
|
|
|||
|
|
@ -892,6 +892,8 @@ fn check_kashida_justification(db: &FontDb, require_fallback: bool) {
|
|||
..Default::default()
|
||||
};
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 260.0, 400.0), lid, text, pf).unwrap();
|
||||
// The Arabic is drawn from a fallback font when the database has one.
|
||||
d.settings.glyph_fallback = true;
|
||||
let composed = |d: &Document| compose_with_db(d, d.story(sid).unwrap(), &frame_specs(d, sid), &ComposeOptions::default(), db);
|
||||
let first = |d: &Document| composed(d).frames[0].lines[0].clone();
|
||||
let with = first(&d);
|
||||
|
|
@ -972,15 +974,13 @@ fn ruby_and_kenten_sit_over_their_text() {
|
|||
fn tate_chu_yoko_sets_digits_across_one_em() {
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 400.0), lid, "令和12年", ParaFormat::default()).unwrap();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 400.0), lid, "令和12年", ParaFormat::default()).unwrap();
|
||||
let at = "令和".len();
|
||||
d.story_mut(sid).unwrap().format_chars(at..at + 2, |f| f.over.tate_chu_yoko = Some(true));
|
||||
let glyphs = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone();
|
||||
// Horizontal text ignores it.
|
||||
assert!(glyphs(&d).iter().all(|g| g.tcy.is_none()));
|
||||
if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) {
|
||||
tf.options.vertical = true;
|
||||
}
|
||||
d.story_mut(sid).unwrap().vertical = true;
|
||||
let gs = glyphs(&d);
|
||||
let digits: Vec<_> = gs.iter().filter(|g| g.tcy.is_some()).collect();
|
||||
assert_eq!(digits.len(), 2);
|
||||
|
|
@ -994,6 +994,20 @@ fn tate_chu_yoko_sets_digits_across_one_em() {
|
|||
let [a1, x1, _] = digits[1].tcy.unwrap();
|
||||
assert!(((digits[0].x + a0) - (digits[1].x + a1)).abs() < 1e-6);
|
||||
assert!(x0 < 0.0 && x1 > x0 && x1 < em, "{x0} {x1}");
|
||||
// Offsets move the turned group: the Y offset along the line, the X offset across it.
|
||||
let origin = |d: &Document| {
|
||||
let cs = compose_story(d, sid, &ComposeOptions::default());
|
||||
let line = &cs.frames[0].lines[0];
|
||||
let g = line.glyphs.iter().find(|g| g.tcy.is_some()).unwrap();
|
||||
g.vertical_xf(line.baseline).unwrap() * Point::new(g.x, line.baseline + g.y)
|
||||
};
|
||||
let plain = origin(&d);
|
||||
d.story_mut(sid).unwrap().format_chars(at..at + 2, |f| {
|
||||
f.over.tate_chu_yoko_x_offset = Some(2.0);
|
||||
f.over.tate_chu_yoko_y_offset = Some(3.0);
|
||||
});
|
||||
let moved = origin(&d);
|
||||
assert!(((moved.x - plain.x) - 3.0).abs() < 1e-6 && ((moved.y - plain.y).abs() - 2.0).abs() < 1e-6, "{plain:?} {moved:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1003,9 +1017,9 @@ fn vertical_frames_compose_in_the_turned_box() {
|
|||
let (fid, sid) =
|
||||
d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 400.0), lid, "縦書きの文章です。Latin", ParaFormat::default()).unwrap();
|
||||
if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) {
|
||||
tf.options.vertical = true;
|
||||
tf.options.inset = [0.0; 4];
|
||||
}
|
||||
d.story_mut(sid).unwrap().vertical = true;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let ft = &cs.frames[0];
|
||||
assert!(ft.vertical);
|
||||
|
|
@ -1016,9 +1030,9 @@ fn vertical_frames_compose_in_the_turned_box() {
|
|||
assert!(l.glyphs.iter().filter(|g| g.len > 0).take(5).all(|g| g.upright));
|
||||
assert!(!l.glyphs.iter().rev().find(|g| g.len > 0).unwrap().upright, "Latin turns");
|
||||
// Text space → frame: the first line sits at the right edge and runs down.
|
||||
let it = d.item(fid).unwrap();
|
||||
let p0 = it.text_xf() * designcraft_geom::Point::new(l.glyphs[0].x, l.baseline);
|
||||
let p1 = it.text_xf() * designcraft_geom::Point::new(l.glyphs[3].x, l.baseline);
|
||||
let xf = d.text_xf(d.item(fid).unwrap());
|
||||
let p0 = xf * designcraft_geom::Point::new(l.glyphs[0].x, l.baseline);
|
||||
let p1 = xf * designcraft_geom::Point::new(l.glyphs[3].x, l.baseline);
|
||||
assert!(p0.x > 150.0 && p1.y > p0.y && (p1.x - p0.x).abs() < 1e-6, "{p0:?} {p1:?}");
|
||||
}
|
||||
|
||||
|
|
@ -1258,12 +1272,13 @@ fn arabic_character_direction_override_survives_line_layout() {
|
|||
#[test]
|
||||
fn arabic_fallback_marks_stay_with_bases_and_custom_offsets_move_only_marks() {
|
||||
let db = designcraft_fonts::FontDb::global();
|
||||
let Some(face) = db.fallback_for('ب', db.face(designcraft_fonts::DEFAULT_FAMILY, "Regular").id()) else { return };
|
||||
let Some(face) = db.fallback_for('ب', db.face(designcraft_fonts::DEFAULT_FAMILY, "Regular").id(), None) else { return };
|
||||
if !face.covers('ُ') {
|
||||
return;
|
||||
}
|
||||
let text = "بُبَ";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 600.0, 200.0), ParaAttrs::default());
|
||||
d.settings.glyph_fallback = true;
|
||||
let plain = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone();
|
||||
assert!(plain.iter().all(|g| g.face.id() == face.id()), "fallback must not split Arabic marks into the Latin face");
|
||||
assert!(plain.iter().all(|g| g.gid != 0));
|
||||
|
|
@ -1291,6 +1306,7 @@ fn arabic_fallback_marks_stay_with_bases_and_custom_offsets_move_only_marks() {
|
|||
fn arabic_joining_context_crosses_character_style_boundaries() {
|
||||
let text = "ببب";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 600.0, 200.0), ParaAttrs::default());
|
||||
d.settings.glyph_fallback = true;
|
||||
let plain = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone();
|
||||
if plain.iter().any(|g| g.gid == 0) {
|
||||
return;
|
||||
|
|
@ -1308,6 +1324,7 @@ fn arabic_character_kashida_switch_prevents_insertion() {
|
|||
Rect::new(0.0, 0.0, 160.0, 300.0),
|
||||
ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::RightJustified), ..Default::default() },
|
||||
);
|
||||
d.settings.glyph_fallback = true;
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.allow_kashidas = Some(false));
|
||||
let off = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(all_lines(&off).iter().all(|l| l.glyphs.iter().all(|g| g.len > 0 || g.face.glyph_for('\u{0640}') != g.gid)));
|
||||
|
|
@ -1330,6 +1347,7 @@ fn arabic_indic_digit_conversion_retains_original_utf8_ranges() {
|
|||
fn arabic_contextual_digits_follow_strong_text_across_style_changes() {
|
||||
let text = "ب 12 A 34";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
d.settings.glyph_fallback = true;
|
||||
let arabic_digits = text.find('1').unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(arabic_digits..arabic_digits + 2, |f| f.over.fill = Some("Paper".into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
|
|
@ -1378,6 +1396,7 @@ fn arabic_kashida_moves_marks_with_their_cluster_and_respects_nonjoiners() {
|
|||
Rect::new(0.0, 0.0, 180.0, 100.0),
|
||||
ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::FullyJustified), ..Default::default() },
|
||||
);
|
||||
d.settings.glyph_fallback = true;
|
||||
let with = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone();
|
||||
if with.iter().any(|g| g.gid == 0) {
|
||||
return;
|
||||
|
|
@ -1392,11 +1411,12 @@ fn arabic_kashida_moves_marks_with_their_cluster_and_respects_nonjoiners() {
|
|||
assert!(((a.x - first_a.x) - (b.x - first_b.x)).abs() < 1e-6, "marks detached from their cluster");
|
||||
}
|
||||
let text = "ب\u{200C}ب ب\u{200C}ب";
|
||||
let (d, sid, _) = doc_with(
|
||||
let (mut d, sid, _) = doc_with(
|
||||
text,
|
||||
Rect::new(0.0, 0.0, 180.0, 100.0),
|
||||
ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::FullyJustified), ..Default::default() },
|
||||
);
|
||||
d.settings.glyph_fallback = true;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(cs.frames[0].lines[0].glyphs.iter().all(|g| g.gid != g.face.glyph_for('\u{0640}') || g.len > 0));
|
||||
}
|
||||
|
|
@ -1405,6 +1425,7 @@ fn arabic_kashida_moves_marks_with_their_cluster_and_respects_nonjoiners() {
|
|||
fn arabic_explicit_isolated_forms_disable_contextual_joining() {
|
||||
let text = "بب";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 180.0, 100.0), ParaAttrs::default());
|
||||
d.settings.glyph_fallback = true;
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.positional_form = Some("Isolated".into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let gs = &cs.frames[0].lines[0].glyphs;
|
||||
|
|
@ -1428,3 +1449,431 @@ fn arabic_character_overrides_mirror_brackets_using_final_levels() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_picks_localized_forms_through_the_word_cache() {
|
||||
// Source Serif 4 has a Turkish `locl` form of `i`; the same words in English and Turkish
|
||||
// (both shaped word by word through the cache) keep their own forms.
|
||||
let text = "in in in in";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 500.0, 200.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(6..text.len(), |f| f.over.language = Some("Turkish".into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let gid_at = |byte: usize| all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).find(|g| g.byte == byte).map(|g| g.gid).unwrap();
|
||||
assert_eq!(gid_at(0), gid_at(3), "English i");
|
||||
assert_eq!(gid_at(6), gid_at(9), "Turkish i");
|
||||
assert_ne!(gid_at(0), gid_at(6), "Turkish i takes its localized form");
|
||||
// A single word is shaped directly, in its language too.
|
||||
let (mut d, sid, _) = doc_with("i", Rect::new(36.0, 36.0, 500.0, 200.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.language = Some("Turkish".into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert_eq!(all_lines(&cs)[0].glyphs[0].gid, gid_at(6), "Turkish i, shaped on its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_and_japanese_take_their_localized_forms() {
|
||||
// Noto Sans CJK SC (craft-fonts) draws 直 differently for Japanese (`locl`).
|
||||
let Some(noto) = designcraft_fonts::CRAFT_FONTS.iter().find(|f| f.family == "Noto Sans CJK SC") else { return };
|
||||
FontDb::global().add_font(noto.bytes.to_vec());
|
||||
let gid = |language: &str| {
|
||||
let (mut d, sid, _) = doc_with("直", Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0.."直".len(), |f| {
|
||||
f.over.font_family = Some(noto.family.into());
|
||||
f.over.language = Some(language.into());
|
||||
});
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let g = &all_lines(&cs)[0].glyphs[0];
|
||||
assert_eq!(g.face.family, noto.family);
|
||||
g.gid
|
||||
};
|
||||
let chinese = gid("Chinese: Simplified");
|
||||
assert_eq!(chinese, FontDb::global().face(noto.family, noto.style).glyph_for('直'));
|
||||
assert_ne!(gid("Japanese"), chinese, "Japanese 直");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_fallback_follows_the_language() {
|
||||
use designcraft_fonts::testing::font_with;
|
||||
// Stand-ins for Hiragino Mincho ProN (the Japanese chain's first system font) and Songti SC
|
||||
// (first in the Simplified Chinese chain); the real ones when installed.
|
||||
let db = designcraft_fonts::FontDb::global();
|
||||
db.add_font(font_with("Hiragino Mincho ProN", &['直']).unwrap());
|
||||
db.add_font(font_with("Songti SC", &['直']).unwrap());
|
||||
// (face of the Latin letter, face of the ideograph)
|
||||
let faces_of = |language: &str| {
|
||||
let (mut d, sid, _) = doc_with("a直", Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default());
|
||||
d.settings.glyph_fallback = true;
|
||||
d.story_mut(sid).unwrap().format_chars(0..4, |f| f.over.language = Some(language.into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let face_at = |byte: usize| all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).find(|g| g.byte == byte).map(|g| g.face).unwrap();
|
||||
(face_at(0), face_at(1))
|
||||
};
|
||||
assert_eq!(faces_of("Chinese: Simplified").1.family, "Songti SC");
|
||||
// Japanese: the craft-fonts faces lead the chain (Mincho first), then the system fonts.
|
||||
let japanese = designcraft_fonts::japanese_document_fonts().first().map_or("Hiragino Mincho ProN", |f| f.family);
|
||||
assert_eq!(faces_of("Japanese").1.family, japanese);
|
||||
// Without a CJK language, the language-blind search.
|
||||
let (primary, han) = faces_of("English: USA");
|
||||
assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_glyphs_are_the_fonts_box_unless_fallback_is_on() {
|
||||
use designcraft_fonts::testing::font_with;
|
||||
// Some font draws 語 when fallback fonts are allowed (a system CJK font, or this stand-in).
|
||||
designcraft_fonts::FontDb::global().add_font(font_with("DC Test Missing Glyph Helper", &['語']).unwrap());
|
||||
let text = "a語";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.language = Some("Japanese".into()));
|
||||
let glyph_at = |d: &Document, byte: usize| {
|
||||
let cs = compose_story(d, sid, &ComposeOptions::default());
|
||||
all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).find(|g| g.byte == byte).map(|g| (g.face, g.gid, g.adv)).unwrap()
|
||||
};
|
||||
assert!(!d.settings.glyph_fallback, "new documents draw missing glyphs as InDesign does");
|
||||
let (face, gid, adv) = glyph_at(&d, 1);
|
||||
assert_eq!((face.family.as_str(), gid), (designcraft_fonts::DEFAULT_FAMILY, 0), "Source Serif 4's .notdef");
|
||||
assert!((adv - face.advance(0) * 12.0 / face.upem).abs() < 1e-6, "its advance: {adv}");
|
||||
d.settings.glyph_fallback = true;
|
||||
let (face, gid, _) = glyph_at(&d, 1);
|
||||
assert_ne!(face.family, designcraft_fonts::DEFAULT_FAMILY);
|
||||
assert_ne!(gid, 0, "a fallback font draws it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kenten_missing_from_the_font_are_its_box_unless_fallback_is_on() {
|
||||
use designcraft_fonts::testing::font_with;
|
||||
// Some font draws the sesame dot when fallback fonts are allowed (a system font, or this one).
|
||||
designcraft_fonts::FontDb::global().add_font(font_with("DC Test Kenten Helper", &['\u{FE45}']).unwrap());
|
||||
let (mut d, sid, _) = doc_with("ab", Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default());
|
||||
let plain = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.len();
|
||||
d.story_mut(sid).unwrap().format_chars(0..2, |f| f.over.kenten = Some(true));
|
||||
let marks = |d: &Document| {
|
||||
let cs = compose_story(d, sid, &ComposeOptions::default());
|
||||
cs.frames[0].lines[0].glyphs[plain..].iter().map(|g| (g.face.family.clone(), g.gid)).collect::<Vec<_>>()
|
||||
};
|
||||
let missing = marks(&d);
|
||||
assert_eq!(missing.len(), 2);
|
||||
assert!(missing.iter().all(|(family, gid)| family == designcraft_fonts::DEFAULT_FAMILY && *gid == 0), "{missing:?}");
|
||||
d.settings.glyph_fallback = true;
|
||||
let drawn = marks(&d);
|
||||
assert_eq!(drawn.len(), 2);
|
||||
assert!(drawn.iter().all(|(family, gid)| family != designcraft_fonts::DEFAULT_FAMILY && *gid != 0), "{drawn:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_document_shapes_words_in_its_own_fonts() {
|
||||
use designcraft_fonts::testing::font_with_glyph;
|
||||
const FAMILY: &str = "DocFont Word Cache";
|
||||
// Two documents' fonts of one name: the same words, drawn as `X` in one and `O` in the other.
|
||||
let root = std::env::temp_dir().join(format!("dc-compose-docfonts-{}", std::process::id()));
|
||||
let mut scopes = Vec::new();
|
||||
for (name, glyph) in [("x", 'X'), ("o", 'O')] {
|
||||
let folder = root.join(name);
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
std::fs::write(folder.join("word.ttf"), font_with_glyph(FAMILY, &['a', 'b', ' '], glyph).unwrap()).unwrap();
|
||||
scopes.push((designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope, glyph));
|
||||
}
|
||||
let text = "ab ab ab ab";
|
||||
let mut faces = Vec::new();
|
||||
for (scope, glyph) in scopes {
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default());
|
||||
d.font_scope = scope;
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let glyphs: Vec<&PlacedGlyph> =
|
||||
all_lines(&cs).into_iter().flat_map(|l| l.glyphs.iter()).filter(|g| text[g.byte..].starts_with('a')).collect();
|
||||
assert_eq!(glyphs.len(), 4);
|
||||
let face = glyphs[0].face;
|
||||
let drawn = designcraft_fonts::FontDb::global().face(designcraft_fonts::FALLBACK_FAMILY, "Regular").glyph_for(glyph);
|
||||
assert!(glyphs.iter().all(|g| g.face == face && g.gid == drawn), "scope {scope}: {glyph}");
|
||||
faces.push(face);
|
||||
}
|
||||
assert!(faces[0] != faces[1] && faces.iter().all(|f| f.family == FAMILY));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_fonts_draw_from_the_documents_fonts() {
|
||||
use designcraft_doc::cjk::{CompositeFont, CompositeFontEntry};
|
||||
use designcraft_fonts::testing::font_with_glyph;
|
||||
const FAMILY: &str = "DocFont Composite Member";
|
||||
// A composite font whose digits come from a font only the document has.
|
||||
let folder = std::env::temp_dir().join(format!("dc-compose-composite-docfont-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
std::fs::write(folder.join("digits.ttf"), font_with_glyph(FAMILY, &['1'], 'X').unwrap()).unwrap();
|
||||
let scope = FontDb::global().load_document_fonts(&folder).scope;
|
||||
let (mut d, sid, _) = doc_with("A1B", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
d.font_scope = scope;
|
||||
std::sync::Arc::make_mut(&mut d.styles).composite_fonts.push(CompositeFont {
|
||||
name: "Mixed".into(),
|
||||
entries: vec![
|
||||
CompositeFontEntry { family: designcraft_fonts::DEFAULT_FAMILY.into(), ..Default::default() },
|
||||
CompositeFontEntry { characters: "0123456789".into(), family: FAMILY.into(), ..Default::default() },
|
||||
],
|
||||
});
|
||||
d.story_mut(sid).unwrap().format_chars(0..3, |f| f.over.font_family = Some("CompositeFont/Mixed".into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let digit = cs.frames[0].lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap();
|
||||
assert_eq!(digit.face.family, FAMILY);
|
||||
assert_ne!(digit.gid, 0);
|
||||
assert!(!cs.styles.iter().any(|s| s.missing_font), "the document's font isn't missing");
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
const KO_WORDS: &str = "한국어 문장은 띄어쓰기 단위로 줄을 바꿉니다 한국어 문장은 띄어쓰기 단위로 줄을 바꿉니다";
|
||||
const KO_HANJA: &str = "大韓民國 憲法은 國民의 權利를 保障한다 大韓民國 憲法은 國民의 權利를 保障한다";
|
||||
const KO_PUNCT: &str = "가나다라마。바사아자차」카타파하가、나다라마바。사아자차카」타파하가나、다라마바사。아자차카타」파하가나다、";
|
||||
/// Syllables for words wider than the frame.
|
||||
const KO_LONG: &str = "가나다라마바사아자차카타파하」";
|
||||
|
||||
/// A Korean paragraph in a narrow frame (70 pt), set in a stand-in font covering the samples.
|
||||
fn korean_doc(text: &str, language: &str, para: ParaAttrs) -> (Document, StoryId) {
|
||||
let chars: Vec<char> = [KO_WORDS, KO_HANJA, KO_PUNCT, KO_LONG].concat().chars().collect();
|
||||
let font = designcraft_fonts::testing::font_with("DC Test Korean", &chars).unwrap();
|
||||
designcraft_fonts::FontDb::global().add_font(font);
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 106.0, 400.0), para);
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| {
|
||||
f.over.font_family = Some("DC Test Korean".into());
|
||||
f.over.language = Some(language.into());
|
||||
});
|
||||
(d, sid)
|
||||
}
|
||||
|
||||
/// The lines (after the first) that start inside a space-separated word.
|
||||
fn lines_starting_mid_word(d: &Document, sid: StoryId, text: &str) -> Vec<String> {
|
||||
let cs = compose_story(d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() > 2, "wraps: {} lines", lines.len());
|
||||
lines.iter().skip(1).filter(|l| !text[..l.range.start].ends_with(' ')).map(|l| text[l.range.clone()].to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_breaks_at_spaces() {
|
||||
for composer in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] {
|
||||
for align in [Align::Left, Align::LeftJustified] {
|
||||
let para = ParaAttrs { composer: Some(composer), align: Some(align), ..Default::default() };
|
||||
// By script: Hangul is set word by word whatever the language.
|
||||
for language in ["Korean", "English: USA"] {
|
||||
let (d, sid) = korean_doc(KO_WORDS, language, para.clone());
|
||||
let mid = lines_starting_mid_word(&d, sid, KO_WORDS);
|
||||
assert!(mid.is_empty(), "{composer:?} {align:?} {language}: {mid:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_breaks_at_spaces_under_a_kinsoku_set() {
|
||||
let set = designcraft_doc::cjk::Kinsoku::named("KoreanKinsoku");
|
||||
assert!(set.is_some());
|
||||
for composer in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] {
|
||||
let para = ParaAttrs { composer: Some(composer), kinsoku: Some(set.clone()), ..Default::default() };
|
||||
let (d, sid) = korean_doc(KO_WORDS, "Korean", para.clone());
|
||||
let mid = lines_starting_mid_word(&d, sid, KO_WORDS);
|
||||
assert!(mid.is_empty(), "{composer:?}: {mid:?}");
|
||||
let (d, sid) = korean_doc(KO_WORDS, "Korean", ParaAttrs { korean_char_breaks: Some(true), ..para });
|
||||
assert!(!lines_starting_mid_word(&d, sid, KO_WORDS).is_empty(), "{composer:?}: breaks between syllables on request");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_character_breaks_on_request() {
|
||||
let para = ParaAttrs { korean_char_breaks: Some(true), composer: Some(designcraft_doc::Composer::SingleLine), ..Default::default() };
|
||||
let (d, sid) = korean_doc(KO_WORDS, "Korean", para);
|
||||
assert!(!lines_starting_mid_word(&d, sid, KO_WORDS).is_empty(), "breaks between syllables");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_by_its_locale_code_breaks_at_spaces() {
|
||||
let para = ParaAttrs { composer: Some(designcraft_doc::Composer::SingleLine), ..Default::default() };
|
||||
let (d, sid) = korean_doc(KO_HANJA, "ko_KR", para);
|
||||
let mid = lines_starting_mid_word(&d, sid, KO_HANJA);
|
||||
assert!(mid.is_empty(), "{mid:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hanja_in_korean_text_breaks_at_spaces() {
|
||||
let para = ParaAttrs { composer: Some(designcraft_doc::Composer::SingleLine), ..Default::default() };
|
||||
let (d, sid) = korean_doc(KO_HANJA, "Korean", para.clone());
|
||||
let mid = lines_starting_mid_word(&d, sid, KO_HANJA);
|
||||
assert!(mid.is_empty(), "{mid:?}");
|
||||
// Han in Japanese text breaks between characters.
|
||||
let (d, sid) = korean_doc(KO_HANJA, "Japanese", para);
|
||||
assert!(!lines_starting_mid_word(&d, sid, KO_HANJA).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_lines_keep_kinsoku() {
|
||||
// Words of 9 to 14 syllables and a closing bracket: in one of them the bracket is what
|
||||
// overflows the frame.
|
||||
let syllables: Vec<char> = KO_LONG.chars().collect();
|
||||
let long = (9..=14).map(|n| format!("{}」 가나", syllables[..n].iter().collect::<String>()));
|
||||
for text in std::iter::once(KO_PUNCT.to_string()).chain(long) {
|
||||
let text = text.as_str();
|
||||
for char_breaks in [false, true] {
|
||||
for composer in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] {
|
||||
let para = ParaAttrs { korean_char_breaks: Some(char_breaks), composer: Some(composer), ..Default::default() };
|
||||
let (d, sid) = korean_doc(text, "Korean", para);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines.len() > 1, "wraps");
|
||||
let texts: Vec<&str> = lines.iter().map(|l| &text[l.range.clone()]).collect();
|
||||
assert!(texts.iter().all(|t| !t.starts_with(['、', '。', '」'])), "kinsoku ({char_breaks}, {composer:?}): {texts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A synthetic font for vertical text: Source Sans 3 glyphs (CJK characters drawn as Latin ones)
|
||||
/// with vertical metrics, every glyph one em down the line and 120 units below its vertical
|
||||
/// origin, except ヸ (drawn as O): 1.024 em. No vertical forms. Added to the global database once.
|
||||
fn vertical_test_font() -> &'static str {
|
||||
use designcraft_fonts::testing::{font_mapping, with_vmtx};
|
||||
const FAMILY: &str = "DC Test Vertical";
|
||||
static ADDED: std::sync::Once = std::sync::Once::new();
|
||||
ADDED.call_once(|| {
|
||||
let glyphs = [('一', 'X'), ('二', 'X'), ('日', 'X'), ('本', 'X'), ('ま', 'o'), ('ヸ', 'O'), ('、', ','), ('A', 'A'), ('b', 'b')];
|
||||
let base = font_mapping(FAMILY, &glyphs).unwrap();
|
||||
let scratch = designcraft_fonts::FontDb::with_font_dirs(vec![]);
|
||||
scratch.add_font(base.clone());
|
||||
let wi = u16::try_from(scratch.face(FAMILY, "Regular").glyph_for('ヸ')).unwrap();
|
||||
designcraft_fonts::FontDb::global().add_font(with_vmtx(&base, (1000, 120), &[(wi, 1024, 100)]).unwrap());
|
||||
});
|
||||
FAMILY
|
||||
}
|
||||
|
||||
/// Shippori Mincho (vertical forms, `vpal`) when built with craft-fonts; else `None`, with a note.
|
||||
fn shippori() -> Option<&'static str> {
|
||||
if !designcraft_fonts::japanese_fonts().any(|f| f.family == "Shippori Mincho") {
|
||||
eprintln!("skipped Shippori Mincho: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
|
||||
return None;
|
||||
}
|
||||
Some("Shippori Mincho")
|
||||
}
|
||||
|
||||
/// The fonts vertical layout is checked in: the synthetic one, and Shippori Mincho with craft-fonts.
|
||||
fn vertical_families() -> Vec<&'static str> {
|
||||
std::iter::once(vertical_test_font()).chain(shippori()).collect()
|
||||
}
|
||||
|
||||
/// A vertical frame (no inset) with `text` in `family` at 20 pt (and `over`), and its first line
|
||||
/// with the frame's first column.
|
||||
fn vertical_line(family: &str, text: &str, over: impl Fn(&mut designcraft_doc::CharFormat)) -> (Line, Rect) {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 500.0), lid, text, ParaFormat::default()).unwrap();
|
||||
if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) {
|
||||
tf.options.inset = [0.0; 4];
|
||||
}
|
||||
d.story_mut(sid).unwrap().vertical = true;
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| {
|
||||
f.over.font_family = Some(family.into());
|
||||
f.over.size = Some(20.0);
|
||||
over(f);
|
||||
});
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let ft = &cs.frames[0];
|
||||
assert!(ft.vertical);
|
||||
let line = ft.lines[0].clone();
|
||||
assert!(line.glyphs.iter().all(|g| g.face.family == family), "{family}: {:?}", line.glyphs.iter().map(|g| &g.face.family).collect::<Vec<_>>());
|
||||
(line, ft.columns[0])
|
||||
}
|
||||
|
||||
/// Text-space bounds of a glyph's ink as drawn in a vertical frame.
|
||||
fn vertical_ink(g: &PlacedGlyph, baseline: f64) -> Rect {
|
||||
use designcraft_geom::{Affine, Shape};
|
||||
let ink = designcraft_fonts::FontDb::global().outline(&g.face, g.gid).bounding_box();
|
||||
let mut a = Affine::translate((g.x, baseline + g.y)) * Affine::scale_non_uniform(g.sx, g.sy);
|
||||
if let Some(turn) = g.vertical_xf(baseline) {
|
||||
a = turn * a;
|
||||
}
|
||||
a.transform_rect_bbox(ink)
|
||||
}
|
||||
|
||||
/// The em box in text space: along the line from x (page down), across from its top (text -y,
|
||||
/// page right) to its bottom → (right, left, centre).
|
||||
fn em_across(l: &Line, g: &PlacedGlyph) -> (f64, f64, f64) {
|
||||
let (top, bottom) = g.face.em_box();
|
||||
let right = l.baseline + g.y - top * g.sy;
|
||||
let left = l.baseline + g.y - bottom * g.sy;
|
||||
(right, left, (right + left) / 2.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_glyphs_hang_on_the_line_centre() {
|
||||
for family in vertical_families() {
|
||||
let (l, _) = vertical_line(family, "一", |_| {});
|
||||
let ichi = &l.glyphs[0];
|
||||
assert!(ichi.upright, "{family}");
|
||||
// An ideograph is centred across the line and inside its em along it.
|
||||
let (_, _, centre) = em_across(&l, ichi);
|
||||
let ink = vertical_ink(ichi, l.baseline);
|
||||
assert!(((ink.y0 + ink.y1) / 2.0 - centre).abs() < 0.05 * 20.0, "{family}: {ink:?} {centre}");
|
||||
assert!(ink.x0 > ichi.x && ink.x1 < ichi.x + ichi.adv, "{family}: {ink:?}");
|
||||
}
|
||||
// The synthetic glyph's top is its top side bearing (120 units) below its vertical origin.
|
||||
let (l, _) = vertical_line(vertical_test_font(), "一", |_| {});
|
||||
let ink = vertical_ink(&l.glyphs[0], l.baseline);
|
||||
assert!((ink.x0 - l.glyphs[0].x - 2.4).abs() < 1e-6, "{ink:?} {}", l.glyphs[0].x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_punctuation_takes_its_vertical_form_and_place() {
|
||||
// A font with vertical forms: Shippori Mincho.
|
||||
let Some(family) = shippori() else { return };
|
||||
let (l, _) = vertical_line(family, "一、", |_| {});
|
||||
let comma = &l.glyphs[1];
|
||||
assert!(comma.upright);
|
||||
assert_ne!(comma.gid, comma.face.glyph_for('、'), "vertical form (vert)");
|
||||
let (right, _, centre) = em_across(&l, comma);
|
||||
// The comma's ink sits in the upper right quarter of its em box on the page.
|
||||
let ink = vertical_ink(comma, l.baseline);
|
||||
assert!(ink.x0 >= comma.x - 1e-6 && ink.x1 < comma.x + comma.adv / 2.0, "upper half: {ink:?} {} {}", comma.x, comma.adv);
|
||||
assert!(ink.y0 >= right - 1e-6 && ink.y1 < centre, "right half: {ink:?} {right} {centre}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_in_vertical_text_turns_as_a_run() {
|
||||
// Shippori Mincho's `vrt2` has turned Latin forms; a turned run must not use them.
|
||||
for family in vertical_families() {
|
||||
let (l, _) = vertical_line(family, "日本Ab", |_| {});
|
||||
let latin: Vec<_> = l.glyphs.iter().filter(|g| g.byte >= "日本".len()).collect();
|
||||
let face = latin[0].face;
|
||||
assert_eq!(latin.iter().map(|g| g.gid).collect::<Vec<_>>(), [face.glyph_for('A'), face.glyph_for('b')], "{family}");
|
||||
assert!(latin.iter().all(|g| !g.upright && g.vertical_xf(l.baseline).is_none()), "{family}");
|
||||
assert!((latin[0].adv - face.advance(latin[0].gid) * latin[0].sx).abs() < 1e-9, "{family}: horizontal advance");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_glyphs_advance_by_their_vertical_metrics() {
|
||||
// ヸ is 1.024 em tall in both fonts.
|
||||
for family in vertical_families() {
|
||||
let (l, _) = vertical_line(family, "一ヸ一", |_| {});
|
||||
let g = &l.glyphs;
|
||||
assert_eq!(g[1].face.v_advance(g[1].gid), 1024.0, "{family}");
|
||||
assert!((g[1].x - g[0].x - 20.0).abs() < 1e-9, "{family}");
|
||||
assert!((g[2].x - g[1].x - 20.48).abs() < 1e-9, "{family}: {} {}", g[1].x, g[2].x);
|
||||
}
|
||||
let Some(family) = shippori() else { return };
|
||||
// Shippori's ヸ is an em wide.
|
||||
let (l, _) = vertical_line(family, "ヸ", |_| {});
|
||||
assert_eq!(l.glyphs[0].face.advance(l.glyphs[0].gid), 1000.0);
|
||||
// Proportional vertical metrics (`vpal`): ま takes 0.974 em.
|
||||
let (l, _) = vertical_line(family, "まま", |f| f.over.otf_features = Some(vec!["vpal".into()]));
|
||||
assert!((l.glyphs[1].x - l.glyphs[0].x - 19.48).abs() < 1e-9, "{:?}", l.glyphs.iter().map(|g| g.x).collect::<Vec<_>>());
|
||||
let (l, _) = vertical_line(family, "まま", |_| {});
|
||||
assert!((l.glyphs[1].x - l.glyphs[0].x - 20.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_lines_fit_the_em_box() {
|
||||
// First baseline at the ascent: an upright line's ascent is its em box top, so the em box
|
||||
// touches the frame's right edge. The synthetic font's em box is Source Sans 3's BASE one
|
||||
// (0.83 em, -0.17 em); Shippori Mincho's its OS/2 typo metrics (0.88 em, -0.12 em).
|
||||
let expected = [(vertical_test_font(), 16.6, 3.4)].into_iter().chain(shippori().map(|f| (f, 17.6, 2.4)));
|
||||
for (family, ascent, descent) in expected {
|
||||
let (l, col) = vertical_line(family, "一二", |_| {});
|
||||
assert!((l.ascent - ascent).abs() < 1e-9 && (l.descent - descent).abs() < 1e-9, "{family}: {} {}", l.ascent, l.descent);
|
||||
assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,133 @@ impl Digits {
|
|||
}
|
||||
}
|
||||
|
||||
/// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese:
|
||||
/// Simplified", or a locale code such as "de_DE_2006"), for the shaper's localized forms (`locl`)
|
||||
/// and everything that depends on the language (quotes, hyphenation and spelling, line breaking,
|
||||
/// fallback fonts). A variant we don't know falls back to its language ("English: Australian" →
|
||||
/// `en`); an unknown language (or "[No Language]") has none. Case, punctuation and IDML's `$ID/`
|
||||
/// don't matter. Locale codes (`ll`, `ll_CC`, `ll-CC`, with a reform year after them as in
|
||||
/// `de_DE_2006`) are their tag (`de-DE`), and Chinese also goes by its other common spellings
|
||||
/// ("Simplified Chinese", "Chinese (Traditional)", "zh_CN", "zh-Hant").
|
||||
pub fn language_tag(language: &str) -> Option<&'static str> {
|
||||
let language = language.trim();
|
||||
let language = language.strip_prefix("$ID/").unwrap_or(language);
|
||||
let l = language.to_lowercase();
|
||||
let words: Vec<&str> = l.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
|
||||
let first = words.first().copied().unwrap_or("");
|
||||
let has = |any: &[&str]| words.iter().any(|w| any.contains(w));
|
||||
if first == "zh" || has(&["chinese"]) {
|
||||
// The script decides; then the name; then the region.
|
||||
return Some(if has(&["hans"]) {
|
||||
"zh-Hans"
|
||||
} else if has(&["hant"]) {
|
||||
"zh-Hant"
|
||||
} else if has(&["simplified", "cn", "sg"]) {
|
||||
"zh-Hans"
|
||||
} else if has(&["traditional", "tw", "hk", "mo"]) {
|
||||
"zh-Hant"
|
||||
} else {
|
||||
"zh"
|
||||
});
|
||||
}
|
||||
if let Some(tag) = locale_code(&words) {
|
||||
return intern_tag(tag);
|
||||
}
|
||||
let variant = match words.join(" ").as_str() {
|
||||
"english usa" => Some("en-US"),
|
||||
"english uk" => Some("en-GB"),
|
||||
"english canadian" => Some("en-CA"),
|
||||
"german swiss 2006 reform" | "german swiss" => Some("de-CH"),
|
||||
"french swiss" => Some("fr-CH"),
|
||||
"italian swiss" => Some("it-CH"),
|
||||
"spanish castilian" => Some("es-ES"),
|
||||
"french canadian" => Some("fr-CA"),
|
||||
"portuguese brazilian" => Some("pt-BR"),
|
||||
"norwegian bokmål" | "norwegian bokmal" => Some("nb"),
|
||||
"norwegian nynorsk" => Some("nn"),
|
||||
_ => None,
|
||||
};
|
||||
if variant.is_some() {
|
||||
return variant;
|
||||
}
|
||||
Some(match first {
|
||||
"english" => "en",
|
||||
"german" => "de",
|
||||
"french" => "fr",
|
||||
"spanish" => "es",
|
||||
"catalan" => "ca",
|
||||
"italian" => "it",
|
||||
"portuguese" => "pt",
|
||||
"dutch" => "nl",
|
||||
"danish" => "da",
|
||||
"swedish" => "sv",
|
||||
"norwegian" => "no",
|
||||
"finnish" => "fi",
|
||||
"polish" => "pl",
|
||||
"czech" => "cs",
|
||||
"slovak" => "sk",
|
||||
"hungarian" => "hu",
|
||||
"romanian" => "ro",
|
||||
"croatian" => "hr",
|
||||
"lithuanian" => "lt",
|
||||
"greek" => "el",
|
||||
"russian" => "ru",
|
||||
"ukrainian" => "uk",
|
||||
"bulgarian" => "bg",
|
||||
"serbian" => "sr",
|
||||
"macedonian" => "mk",
|
||||
"turkish" => "tr",
|
||||
"japanese" => "ja",
|
||||
"korean" => "ko",
|
||||
"arabic" => "ar",
|
||||
"persian" | "farsi" => "fa",
|
||||
"urdu" => "ur",
|
||||
"hebrew" => "he",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The tag of a locale code split into lowercase `words`: a language (2 or 3 letters), then
|
||||
/// optionally a region (2 letters or 3 digits) and a reform year (4 digits) → `ll` or `ll-CC`.
|
||||
fn locale_code(words: &[&str]) -> Option<String> {
|
||||
let letters = |w: &str, n: std::ops::RangeInclusive<usize>| n.contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase());
|
||||
let digits = |w: &str, n: usize| w.len() == n && w.bytes().all(|b| b.is_ascii_digit());
|
||||
match *words {
|
||||
[lang] if letters(lang, 2..=3) => Some(lang.to_string()),
|
||||
[lang, region] | [lang, region, _] if letters(lang, 2..=3) && (letters(region, 2..=2) || digits(region, 3)) => {
|
||||
if let [_, _, year] = *words
|
||||
&& !digits(year, 4)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(format!("{lang}-{}", region.to_ascii_uppercase()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `tag` as a `&'static str`: each distinct tag is kept once for the process. Language names come
|
||||
/// from documents, so at most [`MAX_TAGS`] are kept; past that a new tag is unknown (`None`).
|
||||
fn intern_tag(tag: String) -> Option<&'static str> {
|
||||
const MAX_TAGS: usize = 512;
|
||||
static TAGS: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
|
||||
let mut tags = TAGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(t) = tags.iter().find(|t| **t == tag) {
|
||||
return Some(t);
|
||||
}
|
||||
if tags.len() >= MAX_TAGS {
|
||||
return None;
|
||||
}
|
||||
let t: &'static str = Box::leak(tag.into_boxed_str());
|
||||
tags.push(t);
|
||||
Some(t)
|
||||
}
|
||||
|
||||
/// The language subtag of a BCP 47 tag (`de-DE` → `de`, `zh-Hant` → `zh`).
|
||||
pub fn language_subtag(tag: &str) -> &str {
|
||||
tag.split('-').next().unwrap_or(tag)
|
||||
}
|
||||
|
||||
/// A tracked change on text.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -640,6 +767,9 @@ attr_set! {
|
|||
/// Preserved vendor policy; non-default policies require a dedicated composer.
|
||||
arabic_justification: String = String::new(),
|
||||
paragraph_kashida_width: Option<f64> = None,
|
||||
/// Korean text breaks between any two syllables (character-based breaking); otherwise
|
||||
/// it breaks at spaces, word by word.
|
||||
korean_char_breaks: bool = false,
|
||||
// Keeps
|
||||
keep_with_next: u32 = 0,
|
||||
keep_lines_together: bool = false,
|
||||
|
|
@ -701,6 +831,104 @@ mod tests {
|
|||
assert_eq!(Digits::Hindi.map('x', ""), 'x');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_names_map_to_bcp47_tags() {
|
||||
assert_eq!(language_tag("English: USA"), Some("en-US"));
|
||||
assert_eq!(language_tag("English: Australian"), Some("en"));
|
||||
assert_eq!(language_tag("Turkish"), Some("tr"));
|
||||
assert_eq!(language_tag("Japanese"), Some("ja"));
|
||||
assert_eq!(language_tag("Korean"), Some("ko"));
|
||||
assert_eq!(language_tag("Chinese"), Some("zh"));
|
||||
assert_eq!(language_tag("Chinese: Simplified"), Some("zh-Hans"));
|
||||
assert_eq!(language_tag("Chinese: Traditional"), Some("zh-Hant"));
|
||||
assert_eq!(language_tag("Arabic"), Some("ar"));
|
||||
assert_eq!(language_tag("Persian"), Some("fa"));
|
||||
assert_eq!(language_tag("Farsi"), Some("fa"));
|
||||
assert_eq!(language_tag("Urdu"), Some("ur"));
|
||||
assert_eq!(language_tag("Hebrew"), Some("he"));
|
||||
assert_eq!(language_tag("[No Language]"), None);
|
||||
assert_eq!(language_tag("Klingon"), None);
|
||||
assert_eq!(language_tag(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_japanese_and_korean_names_map_in_their_common_spellings() {
|
||||
for (name, tag) in [
|
||||
("Chinese: Simplified", Some("zh-Hans")),
|
||||
("Simplified Chinese", Some("zh-Hans")),
|
||||
("Chinese Simplified", Some("zh-Hans")),
|
||||
("Chinese (Simplified)", Some("zh-Hans")),
|
||||
(" chinese: SIMPLIFIED ", Some("zh-Hans")),
|
||||
("zh_CN", Some("zh-Hans")),
|
||||
("zh-CN", Some("zh-Hans")),
|
||||
("zh-Hans", Some("zh-Hans")),
|
||||
("zh-Hans-CN", Some("zh-Hans")),
|
||||
("Chinese: Traditional", Some("zh-Hant")),
|
||||
("Traditional Chinese", Some("zh-Hant")),
|
||||
("Chinese Traditional", Some("zh-Hant")),
|
||||
("Chinese (Traditional)", Some("zh-Hant")),
|
||||
("zh_TW", Some("zh-Hant")),
|
||||
("zh-TW", Some("zh-Hant")),
|
||||
("zh_HK", Some("zh-Hant")),
|
||||
("zh-HK", Some("zh-Hant")),
|
||||
("zh-Hant", Some("zh-Hant")),
|
||||
("Chinese", Some("zh")),
|
||||
("zh", Some("zh")),
|
||||
("Japanese", Some("ja")),
|
||||
("ja", Some("ja")),
|
||||
("ja_JP", Some("ja-JP")),
|
||||
("ja-JP", Some("ja-JP")),
|
||||
("Korean", Some("ko")),
|
||||
("ko_KR", Some("ko-KR")),
|
||||
("ko-KR", Some("ko-KR")),
|
||||
("English: USA", Some("en-US")),
|
||||
("German: Swiss 2006 Reform", Some("de-CH")),
|
||||
("Norwegian: Bokmål", Some("nb")),
|
||||
("[No Language]", None),
|
||||
] {
|
||||
assert_eq!(language_tag(name), tag, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_codes_map_to_their_tags() {
|
||||
for (name, tag) in [
|
||||
// As InDesign-exported IDML writes them (AppliedLanguage).
|
||||
("$ID/de_DE_2006", Some("de-DE")),
|
||||
("$ID/nl_NL_2005", Some("nl-NL")),
|
||||
("$ID/English: USA", Some("en-US")),
|
||||
("$ID/English: UK", Some("en-GB")),
|
||||
("$ID/Spanish: Castilian", Some("es-ES")),
|
||||
("$ID/French", Some("fr")),
|
||||
// As IDML import keeps them.
|
||||
("de_DE_2006", Some("de-DE")),
|
||||
("nl_NL_2005", Some("nl-NL")),
|
||||
("Spanish: Castilian", Some("es-ES")),
|
||||
("French", Some("fr")),
|
||||
// Other locale codes.
|
||||
("de-DE", Some("de-DE")),
|
||||
("de_CH_2006", Some("de-CH")),
|
||||
("pt_BR", Some("pt-BR")),
|
||||
("en_us", Some("en-US")),
|
||||
("es_419", Some("es-419")),
|
||||
("fil_PH", Some("fil-PH")),
|
||||
("sv", Some("sv")),
|
||||
("ja", Some("ja")),
|
||||
// Not codes.
|
||||
("de_DE_2006_x", None),
|
||||
("d_DE", None),
|
||||
("de_D", None),
|
||||
("de_DE_06", None),
|
||||
("1e_DE", None),
|
||||
("[No Language]", None),
|
||||
] {
|
||||
assert_eq!(language_tag(name), tag, "{name}");
|
||||
}
|
||||
assert_eq!(language_subtag("de-DE"), "de");
|
||||
assert_eq!(language_subtag("zh-Hant"), "zh");
|
||||
assert_eq!(language_subtag("ko"), "ko");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_and_resolve() {
|
||||
let mut a = CharAttrs { size: Some(10.0), ..Default::default() };
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ impl Document {
|
|||
horizontal_units: nd.units,
|
||||
vertical_units: nd.units,
|
||||
blend_space: if nd.intent == Intent::Print { crate::BlendSpace::Cmyk } else { crate::BlendSpace::Rgb },
|
||||
glyph_fallback: false,
|
||||
..Default::default()
|
||||
};
|
||||
let mut d = Document {
|
||||
|
|
@ -156,6 +157,7 @@ impl Document {
|
|||
created: crate::vars::now(),
|
||||
modified: 0,
|
||||
next_id: 0,
|
||||
font_scope: 0,
|
||||
};
|
||||
let lid = LayerId(d.alloc());
|
||||
d.layers.push(Layer {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_geom::{Point, Rect, shapes};
|
||||
use designcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::{ItemId, LayerId, StoryId};
|
||||
|
|
@ -252,6 +252,8 @@ impl Document {
|
|||
let nid = StoryId(self.alloc());
|
||||
let mut ns = Story::new(nid);
|
||||
ns.frames = before.clone();
|
||||
ns.vertical = other.vertical;
|
||||
ns.direction = other.direction;
|
||||
for f in &before {
|
||||
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
|
||||
t.story = nid;
|
||||
|
|
@ -276,12 +278,12 @@ impl Document {
|
|||
/// stays with the first part and may become overset).
|
||||
pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> {
|
||||
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
|
||||
let tail = {
|
||||
let (tail, vertical, direction) = {
|
||||
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
|
||||
let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?;
|
||||
let tail = st.frames.split_off(p + 1);
|
||||
st.rev += 1;
|
||||
tail
|
||||
(tail, st.vertical, st.direction)
|
||||
};
|
||||
if tail.is_empty() {
|
||||
return Ok(());
|
||||
|
|
@ -289,6 +291,8 @@ impl Document {
|
|||
let nid = StoryId(self.alloc());
|
||||
let mut ns = Story::new(nid);
|
||||
ns.frames = tail.clone();
|
||||
ns.vertical = vertical;
|
||||
ns.direction = direction;
|
||||
self.stories.insert(nid, Arc::new(ns));
|
||||
for f in tail {
|
||||
if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) {
|
||||
|
|
@ -310,6 +314,24 @@ impl Document {
|
|||
p.checked_sub(1).map(|i| st.frames[i])
|
||||
}
|
||||
|
||||
/// Does this text frame set its text vertically (its story is vertical; type on a path stays
|
||||
/// along the path)?
|
||||
pub fn frame_vertical(&self, item: &Item) -> bool {
|
||||
item.text_frame().is_some_and(|t| t.options.path.is_none() && self.story(t.story).is_some_and(|s| s.vertical))
|
||||
}
|
||||
|
||||
/// Text space → item inner space for a frame's composed text (identity except for vertical
|
||||
/// frames).
|
||||
pub fn text_local(&self, item: &Item) -> Affine {
|
||||
if self.frame_vertical(item) { crate::item::vertical_text_xf(item.text_area()) } else { Affine::IDENTITY }
|
||||
}
|
||||
|
||||
/// Text space → spread-parent space for a frame's composed text (the item transform, with the
|
||||
/// quarter turn of a vertical frame).
|
||||
pub fn text_xf(&self, item: &Item) -> Affine {
|
||||
item.xf * self.text_local(item)
|
||||
}
|
||||
|
||||
/// Hit test: the frontmost visible, unlocked-layer item on spread `si` containing `p` (spread space).
|
||||
/// Frames hit by area if they have a fill or are text/graphic frames; otherwise near the outline.
|
||||
pub fn hit_item(&self, si: usize, p: Point, tol: f64) -> Option<ItemId> {
|
||||
|
|
@ -348,6 +370,7 @@ impl Document {
|
|||
"direction": st.direction,
|
||||
"paragraphs": st.paras.len(),
|
||||
"frames": st.frames.iter().map(|f| f.0).collect::<Vec<_>>(),
|
||||
"vertical": st.vertical,
|
||||
"text": st.text,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,9 +332,6 @@ pub struct TextFrameOptions {
|
|||
/// Type on a Path: the text runs along the item's path instead of filling it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathType>,
|
||||
/// Vertical Type: lines run top to bottom and follow each other right to left.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub vertical: bool,
|
||||
}
|
||||
|
||||
/// Type on a Path Options.
|
||||
|
|
@ -380,7 +377,6 @@ impl Default for TextFrameOptions {
|
|||
column_rule_color: designcraft_color::swatch::BLACK.into(),
|
||||
baseline_grid: None,
|
||||
path: None,
|
||||
vertical: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1146,23 +1142,6 @@ impl Item {
|
|||
}
|
||||
|
||||
/// The frame's text area (inner space) after inset.
|
||||
/// Text space → item inner space (identity except for vertical frames).
|
||||
pub fn text_local(&self) -> Affine {
|
||||
match self.text_frame() {
|
||||
Some(t) if t.options.vertical && t.options.path.is_none() => vertical_text_xf(self.text_area()),
|
||||
_ => Affine::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Text space → spread-parent space for this frame's composed text (the item transform, with
|
||||
/// the quarter turn of a vertical frame).
|
||||
pub fn text_xf(&self) -> Affine {
|
||||
match self.text_frame() {
|
||||
Some(t) if t.options.vertical && t.options.path.is_none() => self.xf * vertical_text_xf(self.text_area()),
|
||||
_ => self.xf,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_area(&self) -> Rect {
|
||||
let r = self.inner_bounds();
|
||||
let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]);
|
||||
|
|
|
|||
|
|
@ -340,6 +340,12 @@ pub struct DocSettings {
|
|||
pub overprint_black: bool,
|
||||
/// Type › Track Changes: edits are recorded as inserted / deleted text.
|
||||
pub track_changes: bool,
|
||||
/// Preferences › Composition › Draw Missing Glyphs from Fallback Fonts: characters the
|
||||
/// applied font lacks are drawn from other fonts. Off (InDesign's behaviour, and new
|
||||
/// documents'), they are drawn as the font's missing-glyph box and Preflight lists them.
|
||||
/// Documents saved before the setting existed read as on, the way they were drawn.
|
||||
#[serde(default = "yes")]
|
||||
pub glyph_fallback: bool,
|
||||
}
|
||||
|
||||
impl Default for DocSettings {
|
||||
|
|
@ -371,6 +377,7 @@ impl Default for DocSettings {
|
|||
blend_space: BlendSpace::Cmyk,
|
||||
overprint_black: true,
|
||||
track_changes: false,
|
||||
glyph_fallback: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -515,6 +522,11 @@ pub struct Document {
|
|||
#[serde(default)]
|
||||
pub modified: i64,
|
||||
pub next_id: u64,
|
||||
/// While the document is open, the font scope of the fonts it brought (its `Document Fonts`
|
||||
/// folder): composition, export and the font menus look its fonts up there. 0: none. Not
|
||||
/// saved.
|
||||
#[serde(skip)]
|
||||
pub font_scope: u32,
|
||||
}
|
||||
|
||||
/// What a hyperlink is attached to.
|
||||
|
|
|
|||
|
|
@ -95,6 +95,10 @@ pub struct Story {
|
|||
/// Column progression, independent of paragraph bidi direction and frame threading.
|
||||
#[serde(default)]
|
||||
pub direction: crate::TextDirection,
|
||||
/// Story direction: vertical stories set their lines top to bottom, following each other
|
||||
/// right to left, in every frame of the thread.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub vertical: bool,
|
||||
/// Bumped on every edit (composition cache key).
|
||||
#[serde(default)]
|
||||
pub rev: u64,
|
||||
|
|
@ -136,6 +140,7 @@ impl Story {
|
|||
chars: vec![CharRun { len: 0, format: CharFormat::default() }],
|
||||
frames: vec![],
|
||||
direction: crate::TextDirection::LeftToRight,
|
||||
vertical: false,
|
||||
rev: 0,
|
||||
tables: BTreeMap::new(),
|
||||
notes: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -42,6 +42,58 @@ fn thread_to_empty_frame_converts_it() {
|
|||
assert!(d.thread(a, a).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_keep_their_story_direction_through_threading() {
|
||||
let mut d = doc();
|
||||
let lid = d.default_layer();
|
||||
let r = |x: f64| Rect::new(x, 10.0, x + 50.0, 100.0);
|
||||
let vertical = |d: &Document, f: ItemId| d.item(f).is_some_and(|i| d.frame_vertical(i));
|
||||
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), r(0.0), lid, "縦", ParaFormat::default()).unwrap();
|
||||
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), r(60.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.story_mut(sa).unwrap().vertical = true;
|
||||
// A horizontal frame threaded onto a vertical story turns vertical.
|
||||
d.thread(a, b).unwrap();
|
||||
assert!(vertical(&d, a) && vertical(&d, b));
|
||||
// Threading the vertical story's second frame after a horizontal frame: it turns horizontal,
|
||||
// and the frame left before it stays vertical.
|
||||
let (h, _) = d.add_text_frame(SpreadRef::Doc(0), r(120.0), lid, "横", ParaFormat::default()).unwrap();
|
||||
d.thread(h, b).unwrap();
|
||||
assert!(vertical(&d, a) && !vertical(&d, b) && !vertical(&d, h));
|
||||
// Breaking a vertical thread leaves the frames after the break vertical.
|
||||
let (c, _) = d.add_text_frame(SpreadRef::Doc(0), r(180.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.thread(a, c).unwrap();
|
||||
d.unthread_after(a).unwrap();
|
||||
assert!(vertical(&d, a) && vertical(&d, c));
|
||||
assert_ne!(d.item(c).unwrap().text_frame().unwrap().story, sa);
|
||||
d.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_keep_their_column_direction_through_threading() {
|
||||
use crate::TextDirection::{LeftToRight, RightToLeft};
|
||||
let mut d = doc();
|
||||
let lid = d.default_layer();
|
||||
let r = |x: f64| Rect::new(x, 10.0, x + 50.0, 100.0);
|
||||
let direction = |d: &Document, f: ItemId| d.story(d.item(f).unwrap().text_frame().unwrap().story).unwrap().direction;
|
||||
let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), r(0.0), lid, "نص", ParaFormat::default()).unwrap();
|
||||
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), r(60.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.story_mut(sa).unwrap().direction = RightToLeft;
|
||||
d.thread(a, b).unwrap();
|
||||
assert_eq!((direction(&d, a), direction(&d, b)), (RightToLeft, RightToLeft));
|
||||
// Threading the right-to-left story's second frame after a left-to-right frame: the frame
|
||||
// left before it stays right to left.
|
||||
let (h, _) = d.add_text_frame(SpreadRef::Doc(0), r(120.0), lid, "abc", ParaFormat::default()).unwrap();
|
||||
d.thread(h, b).unwrap();
|
||||
assert_eq!((direction(&d, a), direction(&d, b), direction(&d, h)), (RightToLeft, LeftToRight, LeftToRight));
|
||||
// Breaking a right-to-left thread leaves the frames after the break right to left.
|
||||
let (c, _) = d.add_text_frame(SpreadRef::Doc(0), r(180.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.thread(a, c).unwrap();
|
||||
d.unthread_after(a).unwrap();
|
||||
assert_ne!(d.item(c).unwrap().text_frame().unwrap().story, sa);
|
||||
assert_eq!((direction(&d, a), direction(&d, c)), (RightToLeft, RightToLeft));
|
||||
d.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_last_frame_deletes_story() {
|
||||
let mut d = doc();
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ regex = { workspace = true }
|
|||
quick-xml = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
zip = { workspace = true }
|
||||
hayro-syntax = "0.7.2"
|
||||
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (spread, xf) = match placed {
|
||||
Some((fid, origin)) => {
|
||||
let loc = d0.find(fid).ok_or_else(|| bad("anchored.release", "frame not found"))?;
|
||||
let fxf = d0.parent_xf(&loc) * d0.item(fid).map_or(Affine::IDENTITY, |i| i.text_xf());
|
||||
let fxf = d0.parent_xf(&loc) * d0.item(fid).map_or(Affine::IDENTITY, |i| d0.text_xf(i));
|
||||
(loc.spread, fxf * Affine::translate(origin.to_vec2()))
|
||||
}
|
||||
None => (SpreadRef::Doc(0), Affine::IDENTITY),
|
||||
|
|
|
|||
|
|
@ -196,7 +196,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
let b = s.book.clone().ok_or_else(no_book)?;
|
||||
let mut parts = Vec::new();
|
||||
for path in &b.documents {
|
||||
let d = load(path)?;
|
||||
let mut d = load(path)?;
|
||||
// The chapter's own fonts, for this export (skipped font files are logged).
|
||||
let _fonts = super::file::load_document_fonts(&mut d, path).0;
|
||||
let cache = designcraft_compose::Cache::new();
|
||||
let r = designcraft_pdf::export_pdf_with_report(&d, &cache, &designcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let n = designcraft_render::pdf_page_count(&r.bytes).unwrap_or(0);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
always, file_new),
|
||||
cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample),
|
||||
cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} — .designcraft or .idml", always, file_open),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"),
|
||||
"{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}",
|
||||
always, file_open),
|
||||
cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes),
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save),
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save),
|
||||
|
|
@ -158,9 +160,12 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return super::interchange::open_idml(s, p);
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let d = from_bytes(&bytes)?;
|
||||
let i = s.add_document(DocState::new(d, Some(path.to_string())));
|
||||
Ok(json!({"index": i}))
|
||||
let mut d = from_bytes(&bytes)?;
|
||||
let (fonts, faces, warnings) = load_document_fonts(&mut d, path);
|
||||
let mut st = DocState::new(d, Some(path.to_string()));
|
||||
st.fonts = fonts;
|
||||
let i = s.add_document(st);
|
||||
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings}))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
|
|
@ -169,6 +174,26 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Load the fonts in the `Document Fonts` folder beside the document file at `path` for `d` alone
|
||||
/// (`d.font_scope`) → (the scope, for the document's state to hold; faces; a warning for each
|
||||
/// font file skipped). Nothing on the web (no folders).
|
||||
pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<crate::FontScope>>, usize, Vec<String>) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
|
||||
let Some(folder) = std::path::Path::new(path).parent().map(|d| d.join(DOCUMENT_FONTS_FOLDER)) else { return (None, 0, Vec::new()) };
|
||||
let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder);
|
||||
d.font_scope = r.scope;
|
||||
let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope)));
|
||||
(scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (d, path);
|
||||
(None, 0, Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = base64_decode(str_param(p, "base64").unwrap_or(""));
|
||||
// IDML packages are recognised by their stored `mimetype` first entry.
|
||||
|
|
@ -577,15 +602,18 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let d = if path.to_ascii_lowercase().ends_with(".idml") {
|
||||
let mut d = if path.to_ascii_lowercase().ends_with(".idml") {
|
||||
let bytes = std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
} else {
|
||||
from_bytes(&std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?)?
|
||||
};
|
||||
// As opened again: the fonts beside it now (unchanged files are shared, not read again).
|
||||
let (fonts, _, _) = load_document_fonts(&mut d, &path);
|
||||
let uid = s.doc()?.uid;
|
||||
let st = s.doc_mut()?;
|
||||
let mut fresh = DocState::new(d, Some(path.clone()));
|
||||
fresh.fonts = fonts;
|
||||
fresh.uid = uid;
|
||||
fresh.revision = st.revision + 1;
|
||||
*st = fresh;
|
||||
|
|
@ -656,3 +684,207 @@ mod layout_place_tests {
|
|||
assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod document_fonts_tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use designcraft_fonts::testing::font_with;
|
||||
use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FaceRef, FontDb, FontSource};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("dc-open-docfonts-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join(DOCUMENT_FONTS_FOLDER)).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A new document in `s` with a text frame holding `text` set in `family`.
|
||||
fn new_document(s: &mut Session, family: &str, text: &str) {
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap();
|
||||
s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap();
|
||||
}
|
||||
|
||||
/// A document whose text ("Hello") is set in `family`, written to `path` (`.idml`: as IDML).
|
||||
fn write_document(path: &Path, family: &str) {
|
||||
write_document_with(path, family, "Hello");
|
||||
}
|
||||
|
||||
fn write_document_with(path: &Path, family: &str, text: &str) {
|
||||
let mut s = Session::new();
|
||||
new_document(&mut s, family, text);
|
||||
if path.extension().is_some_and(|e| e == "idml") {
|
||||
std::fs::write(path, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap();
|
||||
} else {
|
||||
s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn font_entry(s: &mut Session, family: &str) -> Value {
|
||||
let l = s.execute("font.list", &json!({})).unwrap();
|
||||
l.as_array().unwrap().iter().find(|f| f["family"] == family).cloned().unwrap_or_else(|| panic!("{family} in {l}"))
|
||||
}
|
||||
|
||||
/// Each character of the active document's text with the face it is drawn in.
|
||||
fn glyph_faces(s: &Session) -> Vec<(char, FaceRef)> {
|
||||
let st = s.doc().unwrap();
|
||||
let mut out = Vec::new();
|
||||
for (sid, story) in &st.doc.stories {
|
||||
let cs = s.cache.get(&st.doc, *sid, None);
|
||||
for g in cs.frames.iter().flat_map(|ft| &ft.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0) {
|
||||
out.push((story.text[g.byte..].chars().next().unwrap(), g.face));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, path: &Path) -> Value {
|
||||
s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_document_loads_its_document_fonts() {
|
||||
const FAMILY: &str = "DocFont Open Test";
|
||||
let dir = temp_dir("designcraft");
|
||||
let fonts = dir.join(DOCUMENT_FONTS_FOLDER);
|
||||
std::fs::write(fonts.join("open.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
|
||||
std::fs::write(fonts.join("broken.otf"), b"not a font").unwrap();
|
||||
let path = dir.join("Brochure.designcraft");
|
||||
write_document(&path, FAMILY);
|
||||
assert!(!FontDb::global().has_family(FAMILY));
|
||||
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["documentFonts"], 1, "{r}");
|
||||
assert!(r["warnings"].to_string().contains("broken.otf"), "a damaged font is reported, not fatal: {r}");
|
||||
let f = font_entry(&mut s, FAMILY);
|
||||
assert_eq!((f["missing"].clone(), f["source"].clone()), (json!(false), json!("document")), "{f}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_an_idml_file_loads_its_document_fonts() {
|
||||
const FAMILY: &str = "DocFont IDML Test";
|
||||
let dir = temp_dir("idml");
|
||||
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("idml.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap();
|
||||
let path = dir.join("Brochure.idml");
|
||||
write_document(&path, FAMILY);
|
||||
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["documentFonts"], 1, "{r}");
|
||||
assert_eq!(font_entry(&mut s, FAMILY)["missing"], false);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_the_folder_opens_as_before() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Plain.designcraft");
|
||||
write_document(&path, designcraft_fonts::DEFAULT_FAMILY);
|
||||
let r = Session::new().execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!((r["documentFonts"].clone(), r["warnings"].clone()), (json!(0), json!([])), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
#[test]
|
||||
fn document_fonts_belong_to_their_document() {
|
||||
const FAMILY: &str = "DocFont Scoped";
|
||||
let (a, b) = (temp_dir("scoped-a"), temp_dir("scoped-b"));
|
||||
std::fs::write(a.join(DOCUMENT_FONTS_FOLDER).join("scoped.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
|
||||
write_document(&a.join("A.designcraft"), FAMILY);
|
||||
write_document(&b.join("B.designcraft"), FAMILY);
|
||||
|
||||
let mut s = Session::new();
|
||||
assert_eq!(open(&mut s, &a.join("A.designcraft"))["documentFonts"], 1);
|
||||
assert_eq!(open(&mut s, &b.join("B.designcraft"))["documentFonts"], 0);
|
||||
// B, open at the same time, doesn't have A's font.
|
||||
assert_eq!(font_entry(&mut s, FAMILY)["missing"], true);
|
||||
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY), "{:?}", glyph_faces(&s));
|
||||
s.execute("file.activate", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(font_entry(&mut s, FAMILY)["source"], "document");
|
||||
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY), "{:?}", glyph_faces(&s));
|
||||
let _ = std::fs::remove_dir_all(&a);
|
||||
let _ = std::fs::remove_dir_all(&b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_document_sets_its_own_font_of_a_name() {
|
||||
const FAMILY: &str = "DocFont Same";
|
||||
let (a, b) = (temp_dir("same-a"), temp_dir("same-b"));
|
||||
let (a_font, b_font) = (a.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"), b.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"));
|
||||
// A's font has both letters, B's only `b`.
|
||||
std::fs::write(&a_font, font_with(FAMILY, &['a', 'b']).unwrap()).unwrap();
|
||||
std::fs::write(&b_font, font_with(FAMILY, &['b']).unwrap()).unwrap();
|
||||
// The same words in both: the word cache must not hand one document's shaping to the other.
|
||||
write_document_with(&a.join("A.designcraft"), FAMILY, "ab ab ab");
|
||||
write_document_with(&b.join("B.designcraft"), FAMILY, "ab ab ab");
|
||||
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &a.join("A.designcraft"));
|
||||
open(&mut s, &b.join("B.designcraft"));
|
||||
// Fallback fonts for the `a` B's font lacks: never A's font.
|
||||
s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap();
|
||||
let in_b = glyph_faces(&s);
|
||||
s.execute("file.activate", &json!({"index": 0})).unwrap();
|
||||
let in_a = glyph_faces(&s);
|
||||
assert_eq!((in_a.len(), in_b.len()), (8, 8), "{in_a:?} {in_b:?}");
|
||||
for (c, f) in &in_a {
|
||||
assert_eq!(f.source, FontSource::Document(a_font.clone()), "`{c}` in A");
|
||||
}
|
||||
for (c, f) in &in_b {
|
||||
match c {
|
||||
'a' => assert!(f.family != FAMILY, "B's font lacks `a`, and A's isn't B's: {f:?}"),
|
||||
_ => assert_eq!(f.source, FontSource::Document(b_font.clone()), "`{c}` in B"),
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&a);
|
||||
let _ = std::fs::remove_dir_all(&b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_document_forgets_its_fonts_and_reopening_reuses_them() {
|
||||
const FAMILY: &str = "DocFont Closed";
|
||||
let dir = temp_dir("closed");
|
||||
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("closed.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
|
||||
let path = dir.join("Closed.designcraft");
|
||||
write_document(&path, FAMILY);
|
||||
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &path);
|
||||
let first = glyph_faces(&s)[0].1;
|
||||
assert_eq!(first.family, FAMILY);
|
||||
s.execute("file.close", &json!({})).unwrap();
|
||||
// A new document can't use the closed document's font.
|
||||
new_document(&mut s, FAMILY, "Hello");
|
||||
assert_eq!(font_entry(&mut s, FAMILY)["missing"], true);
|
||||
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY));
|
||||
// Reopened, the document has its font again: the face loaded the first time.
|
||||
assert_eq!(open(&mut s, &path)["documentFonts"], 1);
|
||||
assert!(glyph_faces(&s).iter().all(|(_, f)| *f == first), "{:?}", glyph_faces(&s));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reverted_document_keeps_its_fonts() {
|
||||
const FAMILY: &str = "DocFont Reverted";
|
||||
let dir = temp_dir("revert");
|
||||
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("revert.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
|
||||
let path = dir.join("Revert.designcraft");
|
||||
write_document(&path, FAMILY);
|
||||
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &path);
|
||||
s.execute("frame.create", &json!({"rect": [72, 400, 200, 500], "content": "text", "text": "x"})).unwrap();
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(font_entry(&mut s, FAMILY)["source"], "document");
|
||||
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use std::collections::BTreeMap;
|
|||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{CharAttrs, Document, Story};
|
||||
use designcraft_fonts::FontSource;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
|
|
@ -12,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}] (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)}] (missing first)", has_doc, |s, _| {
|
||||
Ok(Value::Array(list(&s.doc()?.doc)))
|
||||
}),
|
||||
cmd!(
|
||||
|
|
@ -43,7 +44,7 @@ fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) {
|
|||
}
|
||||
|
||||
fn list(d: &Document) -> Vec<Value> {
|
||||
let db = designcraft_fonts::FontDb::global();
|
||||
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
|
||||
let mut used: BTreeMap<(String, String), usize> = BTreeMap::new();
|
||||
for_each_story(d, &mut |st| {
|
||||
let ranges = st.para_ranges();
|
||||
|
|
@ -73,7 +74,16 @@ fn list(d: &Document) -> Vec<Value> {
|
|||
.map(|((family, style), n)| {
|
||||
let missing = !db.has_family(&family);
|
||||
let style_missing = !missing && !db.styles(&family).iter().any(|s| s.eq_ignore_ascii_case(&style));
|
||||
(missing || style_missing, json!({"family": family, "style": style, "characters": n, "missing": missing, "styleMissing": style_missing}))
|
||||
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}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|(m, _)| !*m);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"{path?, embedImages?: true} — writes an IDML package to `path`, or returns {base64} without a path",
|
||||
has_doc, export_idml),
|
||||
cmd!(noundo "file.openIdml", "Open IDML", [], None,
|
||||
"{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder)",
|
||||
"{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings}",
|
||||
always, open_idml),
|
||||
]
|
||||
}
|
||||
|
|
@ -115,9 +115,15 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(n) = name {
|
||||
d.title = n;
|
||||
}
|
||||
let (fonts, faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) {
|
||||
Some(path) => super::file::load_document_fonts(&mut d, path),
|
||||
None => (None, 0, Vec::new()),
|
||||
};
|
||||
// Never save over the .idml with the native format: the document starts unsaved.
|
||||
let i = s.add_document(DocState::new(d, None));
|
||||
Ok(json!({"index": i}))
|
||||
let mut st = DocState::new(d, None);
|
||||
st.fonts = fonts;
|
||||
let i = s.add_document(st);
|
||||
Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings}))
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
|
|
|
|||
|
|
@ -297,5 +297,6 @@ pub fn file_bytes(d: &designcraft_doc::Document) -> Vec<u8> {
|
|||
pub fn file_from(b: &[u8]) -> Result<designcraft_doc::Document> {
|
||||
file::from_bytes(b)
|
||||
}
|
||||
pub(crate) use file::load_document_fonts;
|
||||
pub use file::{base64_decode, base64_encode, from_bytes, to_bytes};
|
||||
pub(crate) use place_text::autoflow as place_text_autoflow;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Create Frame",
|
||||
[],
|
||||
None,
|
||||
"{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool}",
|
||||
"{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}",
|
||||
has_doc,
|
||||
frame_create
|
||||
),
|
||||
|
|
@ -479,7 +479,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Text Frame Options…",
|
||||
["Object"],
|
||||
Some("Cmd+B"),
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r], verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, vertical?: bool (Vertical Type), ids?}",
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r], verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
has_selection,
|
||||
text_frame_options
|
||||
),
|
||||
|
|
@ -815,10 +815,8 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(it) = d.item_mut(id) {
|
||||
it.path = path;
|
||||
it.shape = sh;
|
||||
if vertical && let Some(tf) = it.text_frame_mut() {
|
||||
tf.options.vertical = true;
|
||||
}
|
||||
}
|
||||
super::text::set_story_direction(d, &[sid], vertical);
|
||||
*sel = if caret {
|
||||
Selection::text(TextSel { story: sid, anchor: text.len(), focus: text.len(), frame: Some(id), cell: None })
|
||||
} else {
|
||||
|
|
@ -1452,9 +1450,6 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(v) = p.get("ignoreWrap").and_then(Value::as_bool) {
|
||||
o.ignore_wrap = v;
|
||||
}
|
||||
if let Some(v) = p.get("vertical").and_then(Value::as_bool) {
|
||||
o.vertical = v;
|
||||
}
|
||||
if let Some(v) = p.get("balanceColumns").and_then(Value::as_bool) {
|
||||
o.balance_columns = v;
|
||||
}
|
||||
|
|
@ -1462,6 +1457,11 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
o.column_rule = v;
|
||||
}
|
||||
}
|
||||
// Story direction belongs to the story: every frame of the thread turns.
|
||||
if let Some(v) = p.get("vertical").and_then(Value::as_bool) {
|
||||
let stories: Vec<StoryId> = ids.iter().filter_map(|id| d.item(*id).and_then(Item::text_frame).map(|t| t.story)).collect();
|
||||
super::text::set_story_direction(d, &stories, v);
|
||||
}
|
||||
Ok(json!({"changed": ids.len()}))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_compose::ComposedStory;
|
||||
use designcraft_doc::{AssetId, Document};
|
||||
use designcraft_fonts::{FaceRef, FontFace, FontSource};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
|
|
@ -16,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Package…",
|
||||
["File"],
|
||||
None,
|
||||
"{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, an IDML copy, an optional PDF and a report → {dir, files, report}",
|
||||
"{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy, an optional PDF and a report → {dir, files, report}",
|
||||
has_doc,
|
||||
package
|
||||
),
|
||||
|
|
@ -36,6 +38,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fn link_name(name: &str, id: AssetId, taken: &mut Vec<String>) -> String {
|
||||
let base =
|
||||
Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("asset-{}", id.0));
|
||||
unique_name(base, taken)
|
||||
}
|
||||
|
||||
/// `base`, or `base` numbered ("logo 2.png"), unique among `taken` (case-insensitively).
|
||||
fn unique_name(base: String, taken: &mut Vec<String>) -> String {
|
||||
let (stem, ext) = match base.rsplit_once('.') {
|
||||
Some((s, e)) => (s.to_string(), format!(".{e}")),
|
||||
None => (base.clone(), String::new()),
|
||||
|
|
@ -74,6 +81,58 @@ fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Do
|
|||
Ok((out, written))
|
||||
}
|
||||
|
||||
/// Copy the font file of `face` into `dir` (created when first needed) unless its licence
|
||||
/// restricts it or it is already there (`copied`: the source files copied) → what the package
|
||||
/// report adds after the font's name (nothing when copied).
|
||||
fn copy_font(face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut Vec<String>, files: &mut Vec<PathBuf>) -> String {
|
||||
let from = match &face.source {
|
||||
FontSource::Bundled => return " — included with DesignCraft".into(),
|
||||
FontSource::Memory => return " — not copied: no font file".into(),
|
||||
FontSource::Installed(p) | FontSource::Document(p) => p,
|
||||
};
|
||||
if face.restricted_licence() {
|
||||
return " — not copied: its licence doesn't allow it".into();
|
||||
}
|
||||
if copied.contains(from) {
|
||||
return String::new();
|
||||
}
|
||||
let name = from.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "font".into());
|
||||
let to = dir.join(unique_name(name, taken));
|
||||
// Packaging into the folder the font is in: it is there already (and copying a file onto
|
||||
// itself empties it on some systems).
|
||||
let same = std::fs::canonicalize(&to).is_ok_and(|t| std::fs::canonicalize(from).is_ok_and(|f| f == t));
|
||||
match std::fs::create_dir_all(dir).and_then(|_| if same { Ok(0) } else { std::fs::copy(from, &to) }) {
|
||||
Ok(_) => {
|
||||
copied.push(from.clone());
|
||||
files.push(to);
|
||||
String::new()
|
||||
}
|
||||
Err(e) => format!(" — not copied: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the faces that draw the visible glyphs of `cs` (table cells and footnotes included) to
|
||||
/// `out`, each once.
|
||||
fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec<FaceRef>) {
|
||||
// Cells and footnotes hold composed stories of their own; tables nest only so deep.
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
for f in &cs.frames {
|
||||
for g in f.lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.visible) {
|
||||
if !out.iter().any(|o| o.id() == g.face.id()) {
|
||||
out.push(g.face);
|
||||
}
|
||||
}
|
||||
for c in f.tables.iter().flat_map(|t| &t.cells) {
|
||||
drawn_faces(&c.text, depth + 1, out);
|
||||
}
|
||||
for n in &f.notes {
|
||||
drawn_faces(&n.text, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?);
|
||||
let st = s.doc()?;
|
||||
|
|
@ -86,6 +145,28 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let doc_path = dir.join(format!("{title}.designcraft"));
|
||||
std::fs::write(&doc_path, super::to_bytes(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", doc_path.display())))?;
|
||||
files.insert(0, doc_path);
|
||||
// The font files, and what the report says about each font.
|
||||
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
|
||||
let (fonts_dir, mut copied, mut taken) = (dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER), Vec::new(), Vec::new());
|
||||
let (mut font_lines, mut named) = (Vec::new(), Vec::new());
|
||||
for f in fonts.as_array().into_iter().flatten() {
|
||||
let (family, style) = (f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""));
|
||||
let missing = f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false);
|
||||
// A missing font's text is drawn in its substitute, which isn't a fallback font.
|
||||
let face = db.face(family, style);
|
||||
named.push(face.id());
|
||||
let note = if missing { " — MISSING".to_string() } else { copy_font(&face, &fonts_dir, &mut copied, &mut taken, &mut files) };
|
||||
font_lines.push(format!(" {family} {style}{note}\n"));
|
||||
}
|
||||
// Fallback fonts: the faces that draw characters the named fonts lack.
|
||||
let mut drawn = Vec::new();
|
||||
for sid in d.stories.keys() {
|
||||
drawn_faces(&s.cache.get(&d, *sid, None), 0, &mut drawn);
|
||||
}
|
||||
drawn.retain(|f| !named.contains(&f.id()));
|
||||
drawn.sort_by(|a, b| (&a.family, &a.style).cmp(&(&b.family, &b.style)));
|
||||
let fallback_lines: Vec<String> =
|
||||
drawn.iter().map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(f, &fonts_dir, &mut copied, &mut taken, &mut files))).collect();
|
||||
if p.get("idml").and_then(Value::as_bool).unwrap_or(true) {
|
||||
let path = dir.join(format!("{title}.idml"));
|
||||
std::fs::write(&path, designcraft_idml::export_idml(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
|
|
@ -103,9 +184,10 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
r += &format!("Instructions\n{t}\n\n");
|
||||
}
|
||||
r += "Fonts\n";
|
||||
for f in fonts.as_array().into_iter().flatten() {
|
||||
let missing = f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false);
|
||||
r += &format!(" {} {}{}\n", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), if missing { " — MISSING" } else { "" });
|
||||
r += &font_lines.concat();
|
||||
if !fallback_lines.is_empty() {
|
||||
r += "\nFallback fonts (for characters the fonts above lack)\n";
|
||||
r += &fallback_lines.concat();
|
||||
}
|
||||
r += "\nLinks\n";
|
||||
for l in links.as_array().into_iter().flatten() {
|
||||
|
|
@ -171,4 +253,95 @@ mod tests {
|
|||
assert!(s.doc().unwrap().doc.assets.values().next().unwrap().link.as_deref().unwrap().contains("Copies"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_copies_the_fonts_their_licences_allow() {
|
||||
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
|
||||
use designcraft_fonts::testing::{font_with, with_fs_type};
|
||||
const FREE: &str = "DocFont Package Free";
|
||||
const RESTRICTED: &str = "DocFont Package Restricted";
|
||||
let dir = std::env::temp_dir().join(format!("dc-package-fonts-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER);
|
||||
std::fs::create_dir_all(&fonts).unwrap();
|
||||
std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap();
|
||||
std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap();
|
||||
// Text in both fonts, and in the bundled default font.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Free Restricted Default"})).unwrap();
|
||||
for (a, b, family) in [(0, 4, FREE), (5, 15, RESTRICTED)] {
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": a, "focus": b})).unwrap();
|
||||
s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap();
|
||||
}
|
||||
let src = dir.join("src").join("Fonts.designcraft");
|
||||
s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap();
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 2);
|
||||
|
||||
let out = dir.join("Pkg");
|
||||
let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap();
|
||||
let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER);
|
||||
assert!(packed_fonts.join("free.ttf").exists());
|
||||
assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out");
|
||||
assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]);
|
||||
let report = r["report"].as_str().unwrap();
|
||||
assert!(report.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}");
|
||||
assert!(report.contains(&format!("{FREE} Regular\n")), "{report}");
|
||||
assert!(report.contains(&format!("{} Regular — included with DesignCraft", designcraft_fonts::DEFAULT_FAMILY)), "{report}");
|
||||
// The packaged document opens with its fonts.
|
||||
let doc = r["files"][0].as_str().unwrap().to_string();
|
||||
assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 1);
|
||||
// Packaging into the folder the fonts come from leaves them whole.
|
||||
let len = std::fs::metadata(fonts.join("free.ttf")).unwrap().len();
|
||||
s.execute("file.package", &json!({"dir": dir.join("src").to_string_lossy(), "idml": false})).unwrap();
|
||||
assert_eq!(std::fs::metadata(fonts.join("free.ttf")).unwrap().len(), len);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_copies_the_fallback_fonts_that_draw_text() {
|
||||
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
|
||||
use designcraft_fonts::testing::{font_with, with_fs_type};
|
||||
const NAMED: &str = "DocFont Fallback Named";
|
||||
const HELPER: &str = "DocFont Fallback Helper";
|
||||
const RESTRICTED: &str = "DocFont Fallback Restricted";
|
||||
// Characters no other font has: the named font lacks both, the document's other fonts
|
||||
// draw them.
|
||||
let (helped, restricted) = ('\u{F0A41}', '\u{F0A42}');
|
||||
let dir = std::env::temp_dir().join(format!("dc-package-fallback-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER);
|
||||
std::fs::create_dir_all(&fonts).unwrap();
|
||||
std::fs::write(fonts.join("named.ttf"), font_with(NAMED, &['N']).unwrap()).unwrap();
|
||||
std::fs::write(fonts.join("helper.ttf"), font_with(HELPER, &[helped]).unwrap()).unwrap();
|
||||
std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &[restricted]).unwrap(), 0x0002).unwrap()).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap();
|
||||
let text = format!("N{helped}{restricted}");
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap();
|
||||
s.execute("type.char", &json!({"fontFamily": NAMED, "fontStyle": "Regular"})).unwrap();
|
||||
let src = dir.join("src").join("Fallback.designcraft");
|
||||
s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap();
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 3);
|
||||
|
||||
let out = dir.join("Pkg");
|
||||
let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap();
|
||||
let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER);
|
||||
assert!(packed_fonts.join("named.ttf").exists());
|
||||
assert!(packed_fonts.join("helper.ttf").exists(), "the fallback font that draws text is copied");
|
||||
assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps a fallback font out too");
|
||||
let report = r["report"].as_str().unwrap();
|
||||
let fallback = report.split("Fallback fonts").nth(1).unwrap_or_else(|| panic!("{report}"));
|
||||
assert!(fallback.contains(&format!("{HELPER} Regular\n")), "{report}");
|
||||
assert!(fallback.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}");
|
||||
assert!(!fallback.contains(NAMED), "{report}");
|
||||
// The packaged document draws the same text with its fonts.
|
||||
let doc = r["files"][0].as_str().unwrap().to_string();
|
||||
assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -273,9 +273,10 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
|
||||
let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id));
|
||||
let text_xf = d.text_xf(&it);
|
||||
let make = |d: &mut Document, ((fill, ft, stroke, stt, sw), bp): ((String, f32, String, f32, f64), BezPath)| {
|
||||
let mut o = Item::new(ItemId(d.alloc()), it.layer, Shape::Path, PathData::from_bezpath(&bp));
|
||||
o.xf = it.text_xf();
|
||||
o.xf = text_xf;
|
||||
o.fill = Fill { swatch: fill, tint: ft, ..Fill::none() };
|
||||
o.stroke = if stroke == designcraft_color::swatch::NONE {
|
||||
Stroke::none()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
//! Live preflight (the Preflight panel and the status-bar indicator): overset text, missing
|
||||
//! fonts, missing or low-resolution graphics, RGB content in print documents, empty text frames.
|
||||
//! fonts and glyphs, missing or low-resolution graphics, RGB content in print documents, empty
|
||||
//! text frames.
|
||||
|
||||
use designcraft_compose::ComposedStory;
|
||||
use designcraft_doc::{Content, Document, Intent, Item, SpreadRef};
|
||||
use designcraft_fonts::FaceRef;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -32,16 +35,54 @@ fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option<usize> {
|
|||
Some(d.first_page_of_spread(si) + sp.page_at_x(it.bounds().center().x).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 };
|
||||
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),
|
||||
Some(_) => {}
|
||||
None => out.push((g.face, vec![c])),
|
||||
}
|
||||
}
|
||||
for c in f.tables.iter().flat_map(|t| &t.cells) {
|
||||
missing_glyphs(&c.text, &c.source, depth + 1, out);
|
||||
}
|
||||
for n in &f.notes {
|
||||
missing_glyphs(&n.text, &n.source, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
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();
|
||||
for story in d.stories.values() {
|
||||
let cs = s.cache.get(d, story.id, None);
|
||||
missing_glyphs(&cs, &story.text, 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));
|
||||
|
|
@ -108,6 +149,18 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
|||
for message in unsupported_typography {
|
||||
out.push(Issue { severity: "warning", kind: "unsupportedTypography", message, item: None, page: None });
|
||||
}
|
||||
for (face, chars) 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() };
|
||||
out.push(Issue {
|
||||
severity: "error",
|
||||
kind: "missingGlyphs",
|
||||
message: format!("Missing glyphs in {} {}: {}{more}", face.family, face.style, listed.join(" ")),
|
||||
item: None,
|
||||
page: None,
|
||||
});
|
||||
}
|
||||
// Items.
|
||||
for (si, sp) in d.spreads.iter().enumerate() {
|
||||
for top in &sp.items {
|
||||
|
|
@ -225,3 +278,47 @@ mod arabic_tests {
|
|||
assert!(typography.iter().all(|i| i.severity == "warning"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod missing_glyph_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn missing_glyphs(s: &mut Session) -> Vec<(String, String)> {
|
||||
let r = s.execute("preflight.run", &json!({})).unwrap();
|
||||
r["issues"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|i| i["kind"] == "missingGlyphs")
|
||||
.map(|i| (i["severity"].as_str().unwrap().to_string(), i["message"].as_str().unwrap().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn characters_drawn_as_missing_glyphs_are_listed() {
|
||||
// Some font has them, for when fallback fonts are on.
|
||||
designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_with("DC Test Preflight Helper", &['日', '本', '語']).unwrap());
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello 日本語 日本 and 語."})).unwrap();
|
||||
let family = designcraft_fonts::DEFAULT_FAMILY;
|
||||
assert_eq!(missing_glyphs(&mut s), [("error".to_string(), format!("Missing glyphs in {family} Regular: 日 本 語"))]);
|
||||
// Drawn from fallback fonts: nothing missing.
|
||||
s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap();
|
||||
assert!(missing_glyphs(&mut s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_list_of_missing_glyphs_is_cut_short() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// 30 private-use characters no font has.
|
||||
let text: String = (0..30).filter_map(|i| char::from_u32(0xF0B00 + i)).collect();
|
||||
s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap();
|
||||
let found = missing_glyphs(&mut s);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(found[0].1.ends_with(" and 10 more"), "{}", found[0].1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const DOC_KEYS: &[&str] = &[
|
|||
"slugColor",
|
||||
"advancedType",
|
||||
"overprintBlack",
|
||||
"glyphFallback",
|
||||
];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
|
@ -47,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Preferences",
|
||||
[],
|
||||
None,
|
||||
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family]} → all application preferences",
|
||||
"{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], showFontNamesInEnglish?} → all application preferences",
|
||||
super::always,
|
||||
|s, p| {
|
||||
let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?;
|
||||
|
|
@ -61,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Document Preferences",
|
||||
[],
|
||||
None,
|
||||
"{horizontalUnits?, verticalUnits?: points|picas|inches|millimeters|…, keyboardIncrement? (pt), baselineGrid?: {start, increment, relativeTo, color, viewThreshold}, grid?: {horizontal, vertical, subdivisions, color, inBack}, pasteboard?: [h, v], marginColor?, columnColor?, bleedColor?, slugColor?: [r, g, b], advancedType?: {superscriptSize, superscriptPosition, subscriptSize, subscriptPosition} (%)} → those settings",
|
||||
"{horizontalUnits?, verticalUnits?: points|picas|inches|millimeters|…, keyboardIncrement? (pt), baselineGrid?: {start, increment, relativeTo, color, viewThreshold}, grid?: {horizontal, vertical, subdivisions, color, inBack}, pasteboard?: [h, v], marginColor?, columnColor?, bleedColor?, slugColor?: [r, g, b], advancedType?: {superscriptSize, superscriptPosition, subscriptSize, subscriptPosition} (%), overprintBlack?, glyphFallback? (draw characters the font lacks from fallback fonts; off: as the font's missing-glyph box)} → those settings",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let cur = serde_json::to_value(&s.doc()?.doc.settings).map_err(|e| bad("document.preferences", e.to_string()))?;
|
||||
|
|
@ -119,6 +120,9 @@ mod tests {
|
|||
let r = s.execute("prefs.set", &json!({"scaleStrokes": false})).unwrap();
|
||||
assert_eq!(r["scaleStrokes"], false);
|
||||
assert!(!s.prefs.scale_strokes);
|
||||
assert_eq!(r["showFontNamesInEnglish"], false, "native CJK font names by default");
|
||||
s.execute("prefs.set", &json!({"showFontNamesInEnglish": true})).unwrap();
|
||||
assert!(s.prefs.show_font_names_in_english);
|
||||
assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err());
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s
|
||||
|
|
@ -264,3 +268,37 @@ mod blend_space_tests {
|
|||
assert!(s.execute("edit.transparencyBlendSpace", &json!({"space": "lab"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod glyph_fallback_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn glyph_fallback_is_an_undoable_document_preference() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("document.preferences", &json!({})).unwrap()["glyphFallback"], false, "new documents: off");
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "語"})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
let gid = |s: &Session| s.cache.get(&s.doc().unwrap().doc, sid, None).frames[0].lines[0].glyphs[0].gid;
|
||||
assert_eq!(gid(&s), 0);
|
||||
designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_with("DC Test Prefs Helper", &['語']).unwrap());
|
||||
assert_eq!(s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap()["glyphFallback"], true);
|
||||
assert!(s.doc().unwrap().doc.settings.glyph_fallback);
|
||||
assert_ne!(gid(&s), 0, "drawn from a fallback font");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.settings.glyph_fallback);
|
||||
assert_eq!(gid(&s), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idml_imports_draw_missing_glyphs_as_boxes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap();
|
||||
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap();
|
||||
assert!(!back.settings.glyph_fallback, "as InDesign draws them");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1150,7 +1150,7 @@ fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Po
|
|||
let loc = st.doc.find(frame)?;
|
||||
let it = st.doc.item_at(&loc)?;
|
||||
let sid = it.text_frame()?.story;
|
||||
let inner = (st.doc.parent_xf(&loc) * it.text_xf()).inverse() * pt;
|
||||
let inner = (st.doc.parent_xf(&loc) * st.doc.text_xf(it)).inverse() * pt;
|
||||
let cs = s.cache.get(&st.doc, sid, None);
|
||||
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
|
||||
let (table, row, col, _) = designcraft_compose::hit_cell(&cs, fi, inner)?;
|
||||
|
|
@ -1396,7 +1396,7 @@ mod sort_tests {
|
|||
let cells = &cs.frames[0].tables[0].cells;
|
||||
let centre = |r: usize| {
|
||||
let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center();
|
||||
d.item(fid).unwrap().text_xf() * c
|
||||
d.text_xf(d.item(fid).unwrap()) * c
|
||||
};
|
||||
let (from, to) = (centre(0), centre(2));
|
||||
s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap();
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
}
|
||||
),
|
||||
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, overset", has_doc, |s, p| {
|
||||
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| {
|
||||
let st = s.doc()?;
|
||||
let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?;
|
||||
let cs = s.cache.get(&st.doc, sid, None);
|
||||
|
|
@ -206,6 +206,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
format_chars(s, &json!({"kenten": on}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"type.storyDirection",
|
||||
"Story Direction",
|
||||
[],
|
||||
None,
|
||||
"{vertical: bool} — the stories of the text selection or the selected frames: vertical lines run top to bottom and follow each other right to left, in every frame of the thread",
|
||||
has_text_or_frames,
|
||||
story_direction
|
||||
),
|
||||
cmd!("type.sizeUp", "Increase Point Size", [], Some("Cmd+Shift+."), "{}", has_text_or_frames, |s, _| step_size(s, 2.0)),
|
||||
cmd!("type.sizeDown", "Decrease Point Size", [], Some("Cmd+Shift+,"), "{}", has_text_or_frames, |s, _| step_size(s, -2.0)),
|
||||
cmd!(
|
||||
|
|
@ -257,7 +266,7 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op
|
|||
let loc = st.doc.find(frame)?;
|
||||
let it = st.doc.item_at(&loc)?;
|
||||
let sid = it.text_frame()?.story;
|
||||
let xf = st.doc.parent_xf(&loc) * it.text_xf();
|
||||
let xf = st.doc.parent_xf(&loc) * st.doc.text_xf(it);
|
||||
let inner = xf.inverse() * pt;
|
||||
let cs = s.cache.get(&st.doc, sid, None);
|
||||
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
|
||||
|
|
@ -469,42 +478,29 @@ fn smart_quotes(s: &Session, text: &str) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
/// Typographer's quotes of a language: [double open, double close, single open, single close].
|
||||
/// Typographer's quotes of a language (a name or locale code, see
|
||||
/// [`designcraft_doc::language_tag`]): [double open, double close, single open, single close].
|
||||
pub fn quote_marks(language: &str) -> [char; 4] {
|
||||
let l = language.to_ascii_lowercase();
|
||||
if l.starts_with("german: swiss") || l.contains("swiss") {
|
||||
['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']
|
||||
} else if l.starts_with("german")
|
||||
|| l.starts_with("czech")
|
||||
|| l.starts_with("slovak")
|
||||
|| l.starts_with("bulgarian")
|
||||
|| l.starts_with("lithuanian")
|
||||
{
|
||||
['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}']
|
||||
} else if l.starts_with("french")
|
||||
|| l.starts_with("russian")
|
||||
|| l.starts_with("ukrainian")
|
||||
|| l.starts_with("norwegian")
|
||||
|| l.starts_with("greek")
|
||||
{
|
||||
['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']
|
||||
} else if l.starts_with("spanish") || l.starts_with("italian") || l.starts_with("portuguese") || l.starts_with("catalan") {
|
||||
['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}']
|
||||
} else if l.starts_with("dutch")
|
||||
|| l.starts_with("polish")
|
||||
|| l.starts_with("romanian")
|
||||
|| l.starts_with("hungarian")
|
||||
|| l.starts_with("croatian")
|
||||
{
|
||||
['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}']
|
||||
} else if l.starts_with("swedish") || l.starts_with("finnish") {
|
||||
['\u{201D}', '\u{201D}', '\u{2019}', '\u{2019}']
|
||||
} else if l.starts_with("japanese") || l.starts_with("chinese") {
|
||||
['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']
|
||||
} else if l.starts_with("danish") {
|
||||
['\u{00BB}', '\u{00AB}', '\u{203A}', '\u{2039}']
|
||||
} else {
|
||||
['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']
|
||||
const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}'];
|
||||
let Some(tag) = designcraft_doc::language_tag(language) else { return ENGLISH };
|
||||
// Swiss German, French and Italian use guillemets.
|
||||
if tag.ends_with("-CH") {
|
||||
return ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}'];
|
||||
}
|
||||
match tag {
|
||||
"zh-Hans" => return ENGLISH,
|
||||
"zh" | "zh-Hant" => return ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'],
|
||||
_ => {}
|
||||
}
|
||||
match designcraft_doc::language_subtag(tag) {
|
||||
"de" | "cs" | "sk" | "bg" | "lt" => ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}'],
|
||||
"fr" | "ru" | "uk" | "no" | "nb" | "nn" | "el" => ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}'],
|
||||
"es" | "it" | "pt" | "ca" => ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}'],
|
||||
"nl" | "pl" | "ro" | "hu" | "hr" => ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}'],
|
||||
"sv" | "fi" => ['\u{201D}', '\u{201D}', '\u{2019}', '\u{2019}'],
|
||||
"da" => ['\u{00BB}', '\u{00AB}', '\u{203A}', '\u{2039}'],
|
||||
"ja" => ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'],
|
||||
_ => ENGLISH,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -594,6 +590,36 @@ fn word_bounds(s: &str, i: usize) -> (usize, usize) {
|
|||
(a, b)
|
||||
}
|
||||
|
||||
/// Type ▸ Story Direction.
|
||||
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
|
||||
let st = s.doc()?;
|
||||
let mut stories: Vec<StoryId> = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect();
|
||||
for id in &st.selection.items {
|
||||
if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame())
|
||||
&& !stories.contains(&tf.story)
|
||||
{
|
||||
stories.push(tf.story);
|
||||
}
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
set_story_direction(d, &stories, vertical);
|
||||
Ok(json!({"stories": stories.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the direction of `stories` (all their frames turn together).
|
||||
pub(crate) fn set_story_direction(d: &mut designcraft_doc::Document, stories: &[StoryId], vertical: bool) {
|
||||
for sid in stories {
|
||||
if let Some(st) = d.story_mut(*sid)
|
||||
&& st.vertical != vertical
|
||||
{
|
||||
st.vertical = vertical;
|
||||
st.rev += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = str_param(p, "dir").unwrap_or("right").to_string();
|
||||
let extend = bool_or(p, "extend", false);
|
||||
|
|
@ -610,8 +636,18 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
None => cs,
|
||||
};
|
||||
let pos = t.focus.min(text.len());
|
||||
// Vertical lines run top to bottom and follow each other right to left: Down and Up move along
|
||||
// the line (Right and Left of horizontal text), Left and Right to the next and previous line.
|
||||
let vertical_text = compose::caret(&cs, pos).and_then(|(fi, ..)| cs.frames.get(fi)).is_some_and(|f| f.vertical);
|
||||
let dir = match (vertical_text, dir.as_str()) {
|
||||
(true, "down") => "right",
|
||||
(true, "up") => "left",
|
||||
(true, "left") => "down",
|
||||
(true, "right") => "up",
|
||||
(_, d) => d,
|
||||
};
|
||||
let collapse_to = |left: bool| if left { t.range().start } else { t.range().end };
|
||||
let new = match dir.as_str() {
|
||||
let new = match dir {
|
||||
"left" if !extend && !t.is_caret() => collapse_to(true),
|
||||
"right" if !extend && !t.is_caret() => collapse_to(false),
|
||||
"left" | "right" => {
|
||||
|
|
@ -1275,6 +1311,90 @@ mod language_tests {
|
|||
let st = s.execute("spelling.check", &json!({"story": sid})).unwrap();
|
||||
assert!(st.as_array().unwrap().is_empty(), "{st}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_languages_have_their_quotes_and_no_english_rules() {
|
||||
use super::quote_marks;
|
||||
assert_eq!(quote_marks("Chinese: Simplified"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']);
|
||||
assert_eq!(quote_marks("Chinese: Traditional"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||||
assert_eq!(quote_marks("Chinese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||||
assert_eq!(quote_marks("Japanese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||||
// Other spellings of the language names take the same quotes.
|
||||
assert_eq!(quote_marks("Simplified Chinese"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']);
|
||||
assert_eq!(quote_marks("zh_CN"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']);
|
||||
assert_eq!(quote_marks("Chinese (Traditional)"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||||
assert_eq!(quote_marks("zh-TW"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||||
assert_eq!(quote_marks("ja_JP"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']);
|
||||
for l in ["Japanese", "Korean", "Chinese", "Chinese: Simplified", "Chinese: Traditional"] {
|
||||
assert!(!designcraft_compose::is_english(l), "{l}: no English hyphenation or spelling");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_coded_languages_take_their_quotes_and_rules() {
|
||||
use super::quote_marks;
|
||||
const GERMAN: [char; 4] = ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}'];
|
||||
const DUTCH: [char; 4] = ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}'];
|
||||
const GUILLEMETS: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}'];
|
||||
const SPANISH: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}'];
|
||||
const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}'];
|
||||
const CORNERS: [char; 4] = ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'];
|
||||
for (language, quotes, english) in [
|
||||
("de_DE_2006", GERMAN, false),
|
||||
("German: 2006 Reform", GERMAN, false),
|
||||
("de_CH_2006", GUILLEMETS, false),
|
||||
("German: Swiss 2006 Reform", GUILLEMETS, false),
|
||||
("nl_NL_2005", DUTCH, false),
|
||||
("Dutch: 2005 Reform", DUTCH, false),
|
||||
("English: USA", ENGLISH, true),
|
||||
("English: UK", ENGLISH, true),
|
||||
("en_GB", ENGLISH, true),
|
||||
("Spanish: Castilian", SPANISH, false),
|
||||
("es_ES", SPANISH, false),
|
||||
("French", GUILLEMETS, false),
|
||||
("fr_FR", GUILLEMETS, false),
|
||||
("nb_NO", GUILLEMETS, false),
|
||||
("ja_JP", CORNERS, false),
|
||||
("[No Language]", ENGLISH, false),
|
||||
] {
|
||||
assert_eq!(quote_marks(language), quotes, "{language}");
|
||||
assert_eq!(designcraft_compose::is_english(language), english, "{language}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idml_language_names_keep_their_spelling_and_pick_the_language() {
|
||||
use std::io::Read;
|
||||
// A stand-in for Songti SC (first in the Simplified Chinese chain; the real one when installed).
|
||||
designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_with("Songti SC", &['直']).unwrap());
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "直"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 3})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"language": "Simplified Chinese"}})).unwrap();
|
||||
let idml = designcraft_idml::export_idml(&s.doc().unwrap().doc);
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(idml.clone())).unwrap();
|
||||
let stories: String = (0..z.len())
|
||||
.map(|i| {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut x = String::new();
|
||||
if f.name().starts_with("Stories/") {
|
||||
f.read_to_string(&mut x).unwrap();
|
||||
}
|
||||
x
|
||||
})
|
||||
.collect();
|
||||
assert!(stories.contains(r#"AppliedLanguage="$ID/Simplified Chinese""#), "{stories}");
|
||||
// The name comes back as written and sets the text as Simplified Chinese.
|
||||
let mut back = designcraft_idml::import_idml(&idml).unwrap();
|
||||
back.settings.glyph_fallback = true;
|
||||
let st = back.stories.values().find(|st| st.text.contains('直')).unwrap();
|
||||
let (_, base) = back.styles.resolve_para(&st.paras[0]);
|
||||
assert_eq!(back.styles.resolve_char(&base, st.char_format_at(0)).language, "Simplified Chinese");
|
||||
let cs = designcraft_compose::compose_story(&back, st.id, &Default::default());
|
||||
let face = cs.frames[0].lines[0].glyphs.iter().find(|g| g.byte == 0).unwrap().face;
|
||||
assert_eq!(face.family, "Songti SC");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -1363,3 +1483,158 @@ mod arabic_tests {
|
|||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::LeftToRight);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod korean_breaks_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn korean_character_breaks_are_a_paragraph_attribute() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "한국어 문장"})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
s.execute("type.para", &json!({"attrs": {"koreanCharBreaks": true}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.korean_char_breaks, Some(true));
|
||||
s.execute("style.paragraph.create", &json!({"name": "Korean", "para": {"koreanCharBreaks": true}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.styles.para("Korean").unwrap().para.korean_char_breaks, Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod story_direction_tests {
|
||||
use designcraft_doc::{ItemId, StoryId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn frame(s: &mut Session, rect: [f64; 4], text: &str, vertical: bool) -> (ItemId, StoryId) {
|
||||
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false, "vertical": vertical})).unwrap();
|
||||
(ItemId(r["id"].as_u64().unwrap()), StoryId(r["story"].as_u64().unwrap()))
|
||||
}
|
||||
|
||||
/// Which frames of the story are composed vertically, in thread order.
|
||||
fn composed(s: &Session, sid: StoryId) -> Vec<bool> {
|
||||
s.cache.get(&s.doc().unwrap().doc, sid, None).frames.iter().map(|f| f.vertical).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_direction_turns_every_frame_of_the_story() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", false);
|
||||
let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false);
|
||||
s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap();
|
||||
assert_eq!(composed(&s, sid), [false, false]);
|
||||
// From the second frame selected, through Type ▸ Story Direction.
|
||||
s.execute("selection.set", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute("type.storyDirection", &json!({"vertical": true})).unwrap();
|
||||
assert_eq!(composed(&s, sid), [true, true]);
|
||||
// From a caret in the text.
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("type.storyDirection", &json!({"vertical": false})).unwrap();
|
||||
assert_eq!(composed(&s, sid), [false, false]);
|
||||
// The frame option sets the frame's story.
|
||||
s.execute("object.textFrameOptions", &json!({"ids": [a.0], "vertical": true})).unwrap();
|
||||
assert_eq!(composed(&s, sid), [true, true]);
|
||||
// Undo puts it back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(composed(&s, sid), [false, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_threaded_onto_a_vertical_story_turns_vertical() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", true);
|
||||
let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false);
|
||||
let c = s.execute("frame.create", &json!({"rect": [450, 72, 550, 400]})).unwrap();
|
||||
let c = ItemId(c["id"].as_u64().unwrap());
|
||||
s.edit(|d, _| {
|
||||
d.thread(a, b)?;
|
||||
Ok(d.thread(b, c)?)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(composed(&s, sid), [true, true, true]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vertical_caret_tests {
|
||||
use designcraft_doc::StoryId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A story set in three or more lines; returns its id.
|
||||
fn story(s: &mut Session, vertical: bool) -> StoryId {
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let text = "一二三四五六七八九十".repeat(8);
|
||||
let rect = if vertical { [72, 72, 200, 400] } else { [72, 72, 400, 200] };
|
||||
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "vertical": vertical})).unwrap();
|
||||
StoryId(r["story"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn mv(s: &mut Session, dir: &str, extend: bool, word: bool) -> usize {
|
||||
s.execute("text.move", &json!({"dir": dir, "extend": extend, "word": word})).unwrap()["pos"].as_u64().unwrap() as usize
|
||||
}
|
||||
|
||||
/// (line index, position along the line) of the caret at `pos`.
|
||||
fn place(s: &Session, sid: StoryId, pos: usize) -> (usize, f64) {
|
||||
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||||
let (_, x, baseline, _, _) = crate::compose::caret(&cs, pos).unwrap();
|
||||
let line = cs.frames[0].lines.iter().position(|l| (l.baseline - baseline).abs() < 0.01).unwrap();
|
||||
(line, x)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_follow_vertical_lines() {
|
||||
let mut s = Session::new();
|
||||
let sid = story(&mut s, true);
|
||||
assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).frames[0].lines.len() >= 3);
|
||||
let c = '一'.len_utf8();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap();
|
||||
// Down and Up move along the line, a character at a time.
|
||||
assert_eq!(mv(&mut s, "down", false, false), 4 * c);
|
||||
assert_eq!(mv(&mut s, "up", false, false), 3 * c);
|
||||
// Left moves to the next line (lines follow each other right to left), keeping the
|
||||
// position along the line; Right comes back.
|
||||
let (line, x) = place(&s, sid, 3 * c);
|
||||
let next = mv(&mut s, "left", false, false);
|
||||
let (nline, nx) = place(&s, sid, next);
|
||||
assert_eq!(nline, line + 1);
|
||||
assert!((nx - x).abs() < 0.5, "{x} {nx}");
|
||||
assert_eq!(mv(&mut s, "right", false, false), 3 * c);
|
||||
// Shift extends.
|
||||
assert_eq!(mv(&mut s, "down", true, false), 4 * c);
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!((t.anchor, t.focus), (3 * c, 4 * c));
|
||||
let p = mv(&mut s, "left", true, false);
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!((t.anchor, t.focus), (3 * c, p));
|
||||
assert_eq!(place(&s, sid, p).0, line + 1);
|
||||
// With a selection, Up collapses it to its start.
|
||||
assert_eq!(mv(&mut s, "up", false, false), 3 * c);
|
||||
// By words: Alt+Down is the vertical Alt+Right.
|
||||
let text = s.doc().unwrap().doc.story(sid).unwrap().text.clone();
|
||||
assert_eq!(mv(&mut s, "down", false, true), super::next_word(&text, 3 * c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_in_horizontal_lines() {
|
||||
let mut s = Session::new();
|
||||
let sid = story(&mut s, false);
|
||||
let c = '一'.len_utf8();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap();
|
||||
assert_eq!(mv(&mut s, "right", false, false), 4 * c);
|
||||
assert_eq!(mv(&mut s, "left", false, false), 3 * c);
|
||||
let (line, x) = place(&s, sid, 3 * c);
|
||||
let next = mv(&mut s, "down", false, false);
|
||||
let (nline, nx) = place(&s, sid, next);
|
||||
assert_eq!(nline, line + 1);
|
||||
assert!((nx - x).abs() < 0.5, "{x} {nx}");
|
||||
assert_eq!(mv(&mut s, "up", false, false), 3 * c);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,20 @@ pub struct DocState {
|
|||
/// Editing parent spreads (Pages panel double-click on a parent).
|
||||
pub editing_parents: bool,
|
||||
pub uid: u64,
|
||||
/// The fonts the document brought (`doc.font_scope`), found while it is open.
|
||||
pub fonts: Option<Arc<FontScope>>,
|
||||
}
|
||||
|
||||
/// A document's font scope ([`designcraft_fonts::FontDb::load_document_fonts`]): its fonts are
|
||||
/// found until the document's state (with every copy of it) is gone — closed, reverted, or its
|
||||
/// session ended.
|
||||
#[derive(Debug)]
|
||||
pub struct FontScope(pub u32);
|
||||
|
||||
impl Drop for FontScope {
|
||||
fn drop(&mut self) {
|
||||
designcraft_fonts::FontDb::global().close_scope(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
|
@ -111,6 +125,7 @@ impl DocState {
|
|||
interaction: None,
|
||||
editing_parents: false,
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
fonts: None,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
|
|
@ -156,6 +171,9 @@ pub struct Prefs {
|
|||
pub rich_black_output: bool,
|
||||
/// Font menu favourites (family names).
|
||||
pub favorite_fonts: Vec<String>,
|
||||
/// Preferences › Type › Show Font Names in English: the font menus show CJK families by their
|
||||
/// English names instead of their native ones. Documents store the English name either way.
|
||||
pub show_font_names_in_english: bool,
|
||||
/// Preferences › Type › Smart Text Reflow: pages follow the primary text frame's story
|
||||
/// (added while it oversets, empty ones at the end removed).
|
||||
pub smart_text_reflow: bool,
|
||||
|
|
@ -209,6 +227,7 @@ impl Default for Prefs {
|
|||
highlight_substituted_fonts: true,
|
||||
rich_black_output: false,
|
||||
favorite_fonts: Vec::new(),
|
||||
show_font_names_in_english: false,
|
||||
smart_text_reflow: true,
|
||||
autocorrect: false,
|
||||
autocorrect_list: default_autocorrect(),
|
||||
|
|
|
|||
|
|
@ -86,12 +86,15 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
|||
for (uid, meta) in list(dir) {
|
||||
let (doc, _) = files(dir, uid);
|
||||
let Ok(bytes) = std::fs::read(&doc) else { continue };
|
||||
let d = match crate::cmd::from_bytes(&bytes) {
|
||||
let mut d = match crate::cmd::from_bytes(&bytes) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let path = meta.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
// Skipped font files are logged; the document opens without them.
|
||||
let fonts = path.as_deref().and_then(|p| crate::cmd::load_document_fonts(&mut d, p).0);
|
||||
let mut st = DocState::new(d, path);
|
||||
st.fonts = fonts;
|
||||
// Unsaved: the copy on disk (if any) is older than what was recovered.
|
||||
st.saved_doc = std::sync::Arc::new((*st.doc).clone());
|
||||
st.revision += 1;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ description = "DesignCraft fonts: font database (bundled OFL + system), metrics,
|
|||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Synthetic fonts for other crates' tests (`designcraft_fonts::testing`).
|
||||
testing = []
|
||||
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,355 @@
|
|||
//! Font menu groups: the script or CJK language a font is for and its family name in that
|
||||
//! language, read from the font's own data.
|
||||
|
||||
use skrifa::raw::TableProvider;
|
||||
|
||||
/// The font menus' groups, in menu order: Western (and other scripts), then Japanese, Simplified
|
||||
/// Chinese, Traditional Chinese and Korean. The order is InDesign's font menu as recalled, not
|
||||
/// observed.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum FontGroup {
|
||||
#[default]
|
||||
Western,
|
||||
Japanese,
|
||||
ChineseSimplified,
|
||||
ChineseTraditional,
|
||||
Korean,
|
||||
}
|
||||
|
||||
/// A family as the font menus list it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FamilyInfo {
|
||||
/// The family name documents store (the English or only name).
|
||||
pub family: String,
|
||||
pub group: FontGroup,
|
||||
/// A CJK family's name in its group's language, when the font has one that differs.
|
||||
pub native: Option<String>,
|
||||
}
|
||||
|
||||
impl FamilyInfo {
|
||||
/// The name the menus show: the native name unless `english` (Show Font Names in English).
|
||||
pub fn label(&self, english: bool) -> &str {
|
||||
match &self.native {
|
||||
Some(n) if !english => n,
|
||||
_ => &self.family,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `query` (lowercase) find this family? Either name matches.
|
||||
pub fn matches(&self, query: &str) -> bool {
|
||||
query.is_empty() || self.family.to_lowercase().contains(query) || self.native.as_ref().is_some_and(|n| n.to_lowercase().contains(query))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort families for a font menu: by group, then by the name shown, ignoring case.
|
||||
pub fn sort_for_menu(families: &mut [FamilyInfo], english: bool) {
|
||||
families.sort_by_cached_key(|f| (f.group, f.label(english).to_lowercase()));
|
||||
}
|
||||
|
||||
/// Language IDs naming a group: Windows (platform 3) and Macintosh (platform 1).
|
||||
fn group_of_language(platform: u16, language: u16) -> Option<FontGroup> {
|
||||
match (platform, language) {
|
||||
(3, 0x0411) | (1, 11) => Some(FontGroup::Japanese),
|
||||
(3, 0x0804 | 0x1004) | (1, 33) => Some(FontGroup::ChineseSimplified),
|
||||
(3, 0x0404 | 0x0C04 | 0x1404) | (1, 19) => Some(FontGroup::ChineseTraditional),
|
||||
(3, 0x0412) | (1, 23) => Some(FontGroup::Korean),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The group of a `meta` script and language tag (`Jpan`, `ja`, `zh-Hant`, `ko-Kore`, …).
|
||||
fn group_of_tag(tag: &str) -> Option<FontGroup> {
|
||||
let tag = tag.trim().to_ascii_lowercase();
|
||||
let parts: Vec<&str> = tag.split(['-', '_']).collect();
|
||||
let has = |p: &str| parts.contains(&p);
|
||||
let first = parts.first().copied().unwrap_or("");
|
||||
if has("jpan") || has("hira") || has("kana") || first == "ja" {
|
||||
Some(FontGroup::Japanese)
|
||||
} else if has("kore") || has("hang") || first == "ko" {
|
||||
Some(FontGroup::Korean)
|
||||
} else if has("hant") || (first == "zh" && (has("tw") || has("hk") || has("mo"))) {
|
||||
Some(FontGroup::ChineseTraditional)
|
||||
} else if has("hans") || first == "zh" {
|
||||
Some(FontGroup::ChineseSimplified)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A font file of `tables` (tag, bytes): the table directory, sorted by tag, then the tables, each
|
||||
/// 4-byte aligned. `None` if there are too many or they are too large to address.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn sfnt(mut tables: Vec<([u8; 4], Vec<u8>)>) -> Option<Vec<u8>> {
|
||||
tables.sort_by_key(|t| t.0);
|
||||
let n = u16::try_from(tables.len()).ok()?;
|
||||
let pow = 1u16 << (15 - n.max(1).leading_zeros().min(15));
|
||||
let mut out = 0x0001_0000_u32.to_be_bytes().to_vec();
|
||||
for v in [n, pow.saturating_mul(16), pow.trailing_zeros() as u16, n.saturating_mul(16).saturating_sub(pow.saturating_mul(16))] {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
for (tag, data) in &tables {
|
||||
out.extend_from_slice(tag);
|
||||
out.extend_from_slice(&0u32.to_be_bytes());
|
||||
out.extend_from_slice(&u32::try_from(offset).ok()?.to_be_bytes());
|
||||
out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_be_bytes());
|
||||
offset = offset.checked_add(data.len().next_multiple_of(4))?;
|
||||
}
|
||||
for (_, data) in &tables {
|
||||
out.extend_from_slice(data);
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Caps on what a (possibly damaged) font can make classification read.
|
||||
const MAX_TAGS: usize = 64;
|
||||
const MAX_RECORDS: usize = 512;
|
||||
|
||||
/// The first group in menu order among `found` (the tie order Japanese, Simplified Chinese,
|
||||
/// Traditional Chinese, Korean).
|
||||
fn first(found: impl Iterator<Item = FontGroup>) -> Option<FontGroup> {
|
||||
found.min()
|
||||
}
|
||||
|
||||
/// The CJK group the font's `meta` table names: `dlng` (the languages it is designed for), else
|
||||
/// `slng` (the ones it supports).
|
||||
fn meta_group(f: &skrifa::FontRef<'_>) -> Option<FontGroup> {
|
||||
use skrifa::raw::tables::meta::{DLNG, Metadata, SLNG};
|
||||
let meta = f.meta().ok()?;
|
||||
let tags = |want| {
|
||||
let map = meta.data_maps().iter().find(|m| m.tag() == want)?;
|
||||
let Ok(Metadata::ScriptLangTags(tags)) = map.data(meta.offset_data()) else { return None };
|
||||
first(tags.iter().take(MAX_TAGS).filter_map(|t| group_of_tag(t.ok()?.as_str())))
|
||||
};
|
||||
tags(DLNG).or_else(|| tags(SLNG))
|
||||
}
|
||||
|
||||
/// The CJK group of the languages the font's `name` table names its family in (style names
|
||||
/// don't count: fonts translate those into many languages).
|
||||
fn name_group(f: &skrifa::FontRef<'_>) -> Option<FontGroup> {
|
||||
let name = f.name().ok()?;
|
||||
first(
|
||||
name.name_record()
|
||||
.iter()
|
||||
.take(MAX_RECORDS)
|
||||
.filter(|r| FAMILY_IDS.contains(&r.name_id().to_u16()))
|
||||
.filter_map(|r| group_of_language(r.platform_id(), r.language_id())),
|
||||
)
|
||||
}
|
||||
|
||||
/// Name IDs of the family name: typographic family, then family.
|
||||
const FAMILY_IDS: [u16; 2] = [16, 1];
|
||||
|
||||
/// From the OS/2 code page ranges: `Some(Some(group))` for the CJK code pages (JIS, Chinese
|
||||
/// Simplified, Korean Wansung, Chinese Traditional, Korean Johab), `Some(None)` when the font
|
||||
/// lists code pages but no CJK one, `None` when it lists none.
|
||||
fn code_page_group(f: &skrifa::FontRef<'_>) -> Option<Option<FontGroup>> {
|
||||
let os2 = f.os2().ok()?;
|
||||
let (r1, r2) = (os2.ul_code_page_range_1()?, os2.ul_code_page_range_2().unwrap_or(0));
|
||||
if r1 == 0 && r2 == 0 {
|
||||
return None;
|
||||
}
|
||||
let bits = [
|
||||
(17, FontGroup::Japanese),
|
||||
(18, FontGroup::ChineseSimplified),
|
||||
(19, FontGroup::Korean),
|
||||
(20, FontGroup::ChineseTraditional),
|
||||
(21, FontGroup::Korean),
|
||||
];
|
||||
Some(first(bits.into_iter().filter(|(b, _)| r1 & (1 << b) != 0).map(|(_, g)| g)))
|
||||
}
|
||||
|
||||
/// The group the font declares: its `meta` languages, then its `name` table's languages, then its
|
||||
/// code pages (the strongest signal first). `None` when it declares nothing at all: no CJK
|
||||
/// language and no code pages.
|
||||
pub(crate) fn declared_group(f: &skrifa::FontRef<'_>) -> Option<FontGroup> {
|
||||
if let Some(g) = meta_group(f).or_else(|| name_group(f)) {
|
||||
return Some(g);
|
||||
}
|
||||
code_page_group(f).map(|g| g.unwrap_or(FontGroup::Western))
|
||||
}
|
||||
|
||||
/// The group by what the font covers: kana is Japanese, Hangul Korean, ideographs alone
|
||||
/// Simplified Chinese.
|
||||
pub(crate) fn covered_group(f: &skrifa::FontRef<'_>) -> FontGroup {
|
||||
use skrifa::MetadataProvider;
|
||||
let cmap = f.charmap();
|
||||
let any = |cs: &[char]| cs.iter().any(|c| cmap.map(*c).is_some());
|
||||
if any(&['あ', 'ア', 'の']) {
|
||||
FontGroup::Japanese
|
||||
} else if any(&['가', '한', '이']) {
|
||||
FontGroup::Korean
|
||||
} else if any(&['一', '中', '人']) {
|
||||
FontGroup::ChineseSimplified
|
||||
} else {
|
||||
FontGroup::Western
|
||||
}
|
||||
}
|
||||
|
||||
/// The font's group and, for a CJK group, its family name in that language (typographic family,
|
||||
/// else family) when it differs from `family`.
|
||||
pub(crate) fn classify(f: &skrifa::FontRef<'_>, family: &str) -> (FontGroup, Option<String>) {
|
||||
let group = declared_group(f).unwrap_or_else(|| covered_group(f));
|
||||
(group, native_family(f, group).filter(|n| n != family))
|
||||
}
|
||||
|
||||
/// The family name (ID 16, else 1) in `group`'s language, from records whose text decodes.
|
||||
fn native_family(f: &skrifa::FontRef<'_>, group: FontGroup) -> Option<String> {
|
||||
if group == FontGroup::Western {
|
||||
return None;
|
||||
}
|
||||
let name = f.name().ok()?;
|
||||
let records = name.name_record();
|
||||
FAMILY_IDS.into_iter().find_map(|id| {
|
||||
records.iter().take(MAX_RECORDS).find_map(|r| {
|
||||
if r.name_id().to_u16() != id || !r.is_unicode() || group_of_language(r.platform_id(), r.language_id()) != Some(group) {
|
||||
return None;
|
||||
}
|
||||
let s: String = r.string(name.string_data()).ok()?.chars().take(256).collect();
|
||||
let s = s.trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::{font_with, with_code_pages, with_meta, with_names, with_table_len};
|
||||
|
||||
const FAMILY: &str = "DC Test Group";
|
||||
|
||||
fn group_of(font: &[u8]) -> (FontGroup, Option<String>) {
|
||||
classify(&skrifa::FontRef::new(font).unwrap(), FAMILY)
|
||||
}
|
||||
|
||||
/// A font with only Latin letters, its code pages `range1` (and none in range 2).
|
||||
fn latin(range1: u32) -> Vec<u8> {
|
||||
with_code_pages(font_with(FAMILY, &['a', 'b']).unwrap(), range1, 0).unwrap()
|
||||
}
|
||||
|
||||
/// Windows English family records plus `extra` records.
|
||||
fn named(font: &[u8], extra: &[(u16, u16, u16, u16, &str)]) -> Vec<u8> {
|
||||
let mut records = vec![(3, 1, 0x409, 1, FAMILY), (3, 1, 0x409, 2, "Regular")];
|
||||
records.extend_from_slice(extra);
|
||||
with_names(font, &records).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_pages_give_the_group() {
|
||||
assert_eq!(group_of(&latin(1)).0, FontGroup::Western, "Latin 1 only");
|
||||
for (bit, group) in [
|
||||
(17, FontGroup::Japanese),
|
||||
(18, FontGroup::ChineseSimplified),
|
||||
(19, FontGroup::Korean),
|
||||
(20, FontGroup::ChineseTraditional),
|
||||
(21, FontGroup::Korean),
|
||||
] {
|
||||
assert_eq!(group_of(&latin(1 | 1 << bit)).0, group, "bit {bit}");
|
||||
}
|
||||
// Several: Japanese, Simplified Chinese, Traditional Chinese, Korean.
|
||||
assert_eq!(group_of(&latin(1 << 17 | 1 << 19)).0, FontGroup::Japanese);
|
||||
assert_eq!(group_of(&latin(1 << 20 | 1 << 21)).0, FontGroup::ChineseTraditional);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_languages_give_the_group_and_the_native_name() {
|
||||
let jp = named(&latin(1), &[(3, 1, 0x0411, 1, "テスト明朝"), (3, 1, 0x0411, 16, "テスト明朝 Pro")]);
|
||||
assert_eq!(group_of(&jp), (FontGroup::Japanese, Some("テスト明朝 Pro".into())), "the typographic family first");
|
||||
for (lang, group) in [
|
||||
(0x0804, FontGroup::ChineseSimplified),
|
||||
(0x1004, FontGroup::ChineseSimplified),
|
||||
(0x0404, FontGroup::ChineseTraditional),
|
||||
(0x0C04, FontGroup::ChineseTraditional),
|
||||
(0x1404, FontGroup::ChineseTraditional),
|
||||
(0x0412, FontGroup::Korean),
|
||||
] {
|
||||
assert_eq!(group_of(&named(&latin(1), &[(3, 1, lang, 1, "本地名")])), (group, Some("本地名".into())), "{lang:#x}");
|
||||
}
|
||||
// Macintosh records in a CJK encoding: the language counts, the text doesn't decode.
|
||||
assert_eq!(group_of(&named(&latin(1), &[(1, 1, 11, 1, "x")])), (FontGroup::Japanese, None));
|
||||
assert_eq!(group_of(&named(&latin(1), &[(1, 3, 23, 1, "x")])).0, FontGroup::Korean);
|
||||
// Records in two CJK languages: the menu order decides.
|
||||
assert_eq!(group_of(&named(&latin(1), &[(3, 1, 0x0412, 1, "한"), (3, 1, 0x0404, 1, "繁")])).0, FontGroup::ChineseTraditional);
|
||||
// Translated style names don't make a Western font CJK.
|
||||
assert_eq!(group_of(&named(&latin(1), &[(3, 1, 0x0411, 2, "標準"), (1, 1, 11, 2, "x")])).0, FontGroup::Western);
|
||||
// A native name equal to the English one isn't shown twice.
|
||||
assert_eq!(group_of(&named(&latin(1), &[(3, 1, 0x0411, 1, FAMILY)])), (FontGroup::Japanese, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_beats_names_which_beat_code_pages() {
|
||||
let all = named(&latin(1 << 17), &[(3, 1, 0x0412, 1, "한글")]);
|
||||
assert_eq!(group_of(&all).0, FontGroup::Korean, "the name table over the code pages");
|
||||
assert_eq!(group_of(&with_meta(&all, Some("zh-Hant"), None).unwrap()).0, FontGroup::ChineseTraditional, "dlng first");
|
||||
assert_eq!(group_of(&with_meta(&all, None, Some("Hans")).unwrap()).0, FontGroup::ChineseSimplified, "slng without dlng");
|
||||
assert_eq!(group_of(&with_meta(&all, Some("Kore, Jpan"), Some("Hans")).unwrap()).0, FontGroup::Japanese, "ties in menu order");
|
||||
// A meta table naming no CJK language leaves it to the rest.
|
||||
assert_eq!(group_of(&with_meta(&all, Some("Latn"), None).unwrap()).0, FontGroup::Korean);
|
||||
for (tag, group) in [
|
||||
("ja", FontGroup::Japanese),
|
||||
("ja-Jpan", FontGroup::Japanese),
|
||||
("zh", FontGroup::ChineseSimplified),
|
||||
("zh-Hans", FontGroup::ChineseSimplified),
|
||||
("zh-TW", FontGroup::ChineseTraditional),
|
||||
("zh-HK", FontGroup::ChineseTraditional),
|
||||
("ko", FontGroup::Korean),
|
||||
("Hang", FontGroup::Korean),
|
||||
] {
|
||||
assert_eq!(group_of_tag(tag), Some(group), "{tag}");
|
||||
}
|
||||
assert_eq!(group_of_tag("en-Latn"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_decides_when_the_font_declares_nothing() {
|
||||
for (chars, group) in [
|
||||
(&['a', 'あ', '一'][..], FontGroup::Japanese),
|
||||
(&['a', '한', '一'][..], FontGroup::Korean),
|
||||
(&['a', '一'][..], FontGroup::ChineseSimplified),
|
||||
(&['a'][..], FontGroup::Western),
|
||||
] {
|
||||
let font = with_code_pages(font_with(FAMILY, chars).unwrap(), 0, 0).unwrap();
|
||||
assert_eq!(group_of(&font).0, group, "{chars:?}");
|
||||
}
|
||||
// Code pages without a CJK one: Western, whatever the font covers.
|
||||
assert_eq!(group_of(&with_code_pages(font_with(FAMILY, &['一']).unwrap(), 1, 0).unwrap()).0, FontGroup::Western);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_tables_classify_without_panicking() {
|
||||
let base = with_meta(&named(&latin(1 << 18), &[(3, 1, 0x0411, 1, "名前")]), Some("Jpan"), None).unwrap();
|
||||
for tag in [b"name", b"OS/2", b"meta", b"cmap"] {
|
||||
for len in [0, 3, 7, 13, 21, 80] {
|
||||
let font = with_table_len(base.clone(), tag, len).unwrap();
|
||||
if let Ok(f) = skrifa::FontRef::new(&font) {
|
||||
let _ = classify(&f, FAMILY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_list_western_fonts_then_each_cjk_language() {
|
||||
let info = |family: &str, group, native: Option<&str>| FamilyInfo { family: family.into(), group, native: native.map(Into::into) };
|
||||
let mut fams = vec![
|
||||
info("Yu Mincho", FontGroup::Japanese, Some("游明朝")),
|
||||
info("Apple SD Gothic Neo", FontGroup::Korean, Some("애플 SD 산돌고딕 Neo")),
|
||||
info("Songti SC", FontGroup::ChineseSimplified, Some("宋体-简")),
|
||||
info("zapfino", FontGroup::Western, None),
|
||||
info("Hiragino Mincho ProN", FontGroup::Japanese, Some("ヒラギノ明朝 ProN")),
|
||||
info("PMingLiU", FontGroup::ChineseTraditional, Some("新細明體")),
|
||||
info("Arial", FontGroup::Western, None),
|
||||
];
|
||||
sort_for_menu(&mut fams, true);
|
||||
let order: Vec<&str> = fams.iter().map(|f| f.label(true)).collect();
|
||||
assert_eq!(order, ["Arial", "zapfino", "Hiragino Mincho ProN", "Yu Mincho", "Songti SC", "PMingLiU", "Apple SD Gothic Neo"]);
|
||||
sort_for_menu(&mut fams, false);
|
||||
let order: Vec<&str> = fams.iter().map(|f| f.label(false)).collect();
|
||||
assert_eq!(order, ["Arial", "zapfino", "ヒラギノ明朝 ProN", "游明朝", "宋体-简", "新細明體", "애플 SD 산돌고딕 Neo"]);
|
||||
// Search finds either name.
|
||||
let yu = fams.iter().find(|f| f.family == "Yu Mincho").unwrap();
|
||||
assert!(yu.matches("yu min") && yu.matches("游") && yu.matches("") && !yu.matches("songti"));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,10 +8,18 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
mod fontdb;
|
||||
mod group;
|
||||
#[cfg(any(test, feature = "testing"))]
|
||||
pub mod testing;
|
||||
mod vertical;
|
||||
|
||||
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, base_style, bundled, system_font_dirs};
|
||||
pub use fontdb::{
|
||||
DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES,
|
||||
MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, system_font_dirs,
|
||||
};
|
||||
pub use group::{FamilyInfo, FontGroup, sort_for_menu};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
|
||||
use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer};
|
||||
pub use kurbo::BezPath;
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
|
|
@ -52,13 +60,19 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
|
|||
/// InDesign's default text font is a serif; ours is Source Serif 4.
|
||||
pub const DEFAULT_FAMILY: &str = "Source Serif 4";
|
||||
|
||||
/// One shaped glyph, in font units.
|
||||
/// One shaped glyph, in font units (y up).
|
||||
///
|
||||
/// Horizontal runs ([`shape`]) advance by `x_advance` (`y_advance` is 0) and the offsets move the
|
||||
/// glyph from its pen position. Vertical runs ([`shape_vertical`]) advance down the line by
|
||||
/// `-y_advance` (`x_advance` is 0) and the offsets move the glyph from its vertical origin
|
||||
/// ([`FontFace::v_origin`]) on the line's centre, as `vpal` or `vkrn` place it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ShapedGlyph {
|
||||
pub gid: u32,
|
||||
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
|
||||
pub cluster: usize,
|
||||
pub x_advance: i32,
|
||||
pub y_advance: i32,
|
||||
pub x_offset: i32,
|
||||
pub y_offset: i32,
|
||||
pub safe_tatweel_before: bool,
|
||||
|
|
@ -113,23 +127,14 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool, unicode_scri
|
|||
runs
|
||||
}
|
||||
|
||||
/// What surrounds a run being shaped: the text before and after it (so Arabic letters join across a
|
||||
/// change of style) and its language, a BCP 47 tag such as `tr` or `zh-Hant` that picks the font's
|
||||
/// localized forms (OpenType `locl`; `None` or a tag the shaper can't read: the font's defaults).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct ShapeContext<'a> {
|
||||
pub before: &'a str,
|
||||
pub after: &'a str,
|
||||
pub language: &'a str,
|
||||
}
|
||||
|
||||
/// Normalize the supported application language names while also accepting BCP 47 tags.
|
||||
pub fn language_tag(language: &str) -> &str {
|
||||
match language.split(':').next().unwrap_or(language).trim() {
|
||||
"Arabic" => "ar",
|
||||
"Persian" | "Farsi" => "fa",
|
||||
"Urdu" => "ur",
|
||||
"Hebrew" => "he",
|
||||
"English" => "en",
|
||||
_ => language,
|
||||
}
|
||||
pub language: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
|
||||
|
|
@ -140,6 +145,7 @@ pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(cha
|
|||
shape_with_context(face, text, features, map, ShapeContext::default())
|
||||
}
|
||||
|
||||
/// [`shape`] `text` in its `context`: its language and the text around it.
|
||||
pub fn shape_with_context(
|
||||
face: &FontFace,
|
||||
text: &str,
|
||||
|
|
@ -196,7 +202,7 @@ fn shape_dir(
|
|||
if let Ok(script) = script.short_name().parse() {
|
||||
buf.set_script(script);
|
||||
}
|
||||
if let Ok(language) = language_tag(context.language).parse() {
|
||||
if let Some(language) = context.language.and_then(|t| t.parse::<Language>().ok()) {
|
||||
buf.set_language(language);
|
||||
}
|
||||
buf.set_flags(harfrust::BufferFlags::PRODUCE_SAFE_TO_INSERT_TATWEEL);
|
||||
|
|
@ -208,6 +214,7 @@ fn shape_dir(
|
|||
gid: info.glyph_id,
|
||||
cluster: info.cluster as usize,
|
||||
x_advance: pos.x_advance,
|
||||
y_advance: 0,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
safe_tatweel_before: info.safe_to_insert_tatweel(),
|
||||
|
|
@ -227,6 +234,7 @@ fn shape_dir(
|
|||
gid: g.to_u32(),
|
||||
cluster: i,
|
||||
x_advance: adv.round() as i32,
|
||||
y_advance: 0,
|
||||
x_offset: 0,
|
||||
y_offset: 0,
|
||||
safe_tatweel_before: false,
|
||||
|
|
@ -237,6 +245,70 @@ fn shape_dir(
|
|||
out
|
||||
}
|
||||
|
||||
/// Shape upright text top to bottom (`text`, `features`, `map` and `context` as in
|
||||
/// [`shape_with_context`]): the shaper applies `vert` (vertical forms), and `vkrn` and `vpal` when
|
||||
/// `features` turn them on. Advances and origins are the face's vertical metrics
|
||||
/// ([`FontFace::v_advance`], [`FontFace::v_origin`]).
|
||||
pub fn shape_vertical(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, context: ShapeContext<'_>) -> Vec<ShapedGlyph> {
|
||||
let mut funcs = vertical::VerticalFuncs { face, metrics: vertical::VMetrics::new(face) };
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let mut origins = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
buf.add(map(c), i as u32);
|
||||
}
|
||||
buf.set_pre_context(context.before);
|
||||
buf.set_post_context(context.after);
|
||||
if let Some(language) = context.language.and_then(|t| t.parse::<Language>().ok()) {
|
||||
buf.set_language(language);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(Direction::TopToBottom);
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(features).font_funcs(Some(&mut funcs)));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
out.push(ShapedGlyph {
|
||||
gid: info.glyph_id,
|
||||
cluster: info.cluster as usize,
|
||||
x_advance: pos.x_advance,
|
||||
y_advance: pos.y_advance,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
safe_tatweel_before: false,
|
||||
rtl: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
if shaped.is_none()
|
||||
&& let Some(f) = face.skrifa()
|
||||
{
|
||||
let cmap = f.charmap();
|
||||
for (i, c) in text.char_indices() {
|
||||
let gid = cmap.map(map(c)).unwrap_or_default().to_u32();
|
||||
let (x, y) = funcs.origin(gid);
|
||||
let adv = -(face.v_advance(gid).round() as i32);
|
||||
out.push(ShapedGlyph {
|
||||
gid,
|
||||
cluster: i,
|
||||
x_advance: 0,
|
||||
y_advance: adv,
|
||||
x_offset: -x,
|
||||
y_offset: -y,
|
||||
safe_tatweel_before: false,
|
||||
rtl: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
// The shaper hangs each glyph from its vertical origin; keep only what GPOS moved it by.
|
||||
origins.extend(out.iter().map(|g| funcs.origin(g.gid)));
|
||||
for (g, (x, y)) in out.iter_mut().zip(origins) {
|
||||
g.x_offset = g.x_offset.saturating_add(x);
|
||||
g.y_offset = g.y_offset.saturating_add(y);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Glyph id of the first of `chars` the face has (0 = .notdef).
|
||||
pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
|
||||
chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0)
|
||||
|
|
@ -254,10 +326,6 @@ mod tests {
|
|||
assert_eq!(runs.len(), 2);
|
||||
assert_eq!(runs[0], (0..5, true, Script::Hebrew));
|
||||
assert_eq!(runs[1], (5..text.len(), true, Script::Arabic));
|
||||
assert_eq!(language_tag("Arabic"), "ar");
|
||||
assert_eq!(language_tag("Farsi"), "fa");
|
||||
assert_eq!(language_tag("Urdu"), "ur");
|
||||
assert_eq!(language_tag("ar-SA"), "ar-SA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -297,6 +365,17 @@ mod tests {
|
|||
assert_eq!(off.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_picks_localized_forms() {
|
||||
// Source Serif 4's `locl` has a Turkish `i`.
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
let gid = |language| shape_with_context(&face, "i", &[], |c| c, ShapeContext { language, ..Default::default() })[0].gid;
|
||||
assert_eq!(gid(None), face.glyph_for('i'));
|
||||
assert_eq!(gid(Some("en-US")), face.glyph_for('i'));
|
||||
assert_ne!(gid(Some("tr")), face.glyph_for('i'));
|
||||
assert_eq!(gid(Some("not a tag")), face.glyph_for('i'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_keeps_clusters() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
|
|
@ -357,6 +436,49 @@ mod tests {
|
|||
assert!(feature("abc").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notdef_without_an_outline_is_drawn_as_a_box() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let font = testing::font_with("DC Test Empty Notdef", &['a']).unwrap();
|
||||
db.add_font(testing::with_empty_notdef(font).unwrap());
|
||||
let face = db.face("DC Test Empty Notdef", "Regular");
|
||||
assert!(face.skrifa().unwrap().outline_glyphs().get(skrifa::GlyphId::NOTDEF).is_some_and(|g| {
|
||||
let mut pen = kurbo::BezPath::new();
|
||||
let _ = g.draw(skrifa::outline::DrawSettings::unhinted(skrifa::instance::Size::unscaled(), face.location()), &mut BezPen(&mut pen));
|
||||
pen.elements().is_empty()
|
||||
}));
|
||||
// The box spans the glyph's advance, above the baseline (outlines are y-down).
|
||||
let b = kurbo::Shape::bounding_box(&*db.outline(&face, 0));
|
||||
assert!(b.width() > 0.0 && b.width() <= face.advance(0), "{b:?}");
|
||||
assert!(b.y0 < 0.0 && b.y1 <= 0.0, "{b:?}");
|
||||
assert!(db.missing_box(&face).is_some());
|
||||
// A font's own .notdef stays as drawn.
|
||||
let serif = db.face(DEFAULT_FAMILY, "Regular");
|
||||
assert!(db.missing_box(&serif).is_none());
|
||||
assert!(!db.outline(&serif, 0).elements().is_empty());
|
||||
}
|
||||
|
||||
struct BezPen<'a>(&'a mut kurbo::BezPath);
|
||||
|
||||
impl skrifa::outline::OutlinePen for BezPen<'_> {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.0.move_to((x as f64, y as f64));
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.0.line_to((x as f64, y as f64));
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
self.0.quad_to((cx0 as f64, cy0 as f64), (x as f64, y as f64));
|
||||
}
|
||||
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
||||
self.0.curve_to((cx0 as f64, cy0 as f64), (cx1 as f64, cy1 as f64), (x as f64, y as f64));
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn craft_fonts_japanese_faces_cover_japanese_and_have_vertical_forms() {
|
||||
if CRAFT_FONTS.is_empty() {
|
||||
|
|
@ -383,10 +505,13 @@ mod tests {
|
|||
assert_eq!(horizontal.len(), vertical.len());
|
||||
assert!(horizontal.iter().zip(&vertical).any(|(h, v)| h.gid != v.gid), "{} has vertical forms", cf.family);
|
||||
}
|
||||
// Japanese in a Latin face falls back to a craft-fonts Mincho, without system fonts.
|
||||
// Japanese in a Latin face falls back to a craft-fonts Mincho, without system fonts, in
|
||||
// Japanese text (the Japanese chain) and without a language (the first loaded face).
|
||||
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
||||
let fb = db.fallback_for('語', latin.id()).unwrap();
|
||||
assert!(fb.family.contains("Mincho"), "{fb:?}");
|
||||
for language in [Some("ja"), None] {
|
||||
let fb = db.fallback_for('語', latin.id(), language).unwrap();
|
||||
assert!(fb.family.contains("Mincho"), "{language:?}: {fb:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -399,7 +524,9 @@ mod tests {
|
|||
if CRAFT_FONTS.is_empty() {
|
||||
assert!(japanese_fonts().next().is_none() && japanese_document_fonts().is_empty());
|
||||
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
||||
assert!(db.fallback_for('語', latin.id()).is_none(), "no bundled Japanese font");
|
||||
for language in [Some("ja"), None] {
|
||||
assert!(db.fallback_for('語', latin.id(), language).is_none(), "no bundled Japanese font: {language:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
//! Synthetic fonts for tests (here and in dependent crates, through the `testing` feature).
|
||||
|
||||
use skrifa::MetadataProvider;
|
||||
|
||||
/// A font named `family` (style Regular) that maps exactly `chars`, all to one glyph (`X`): the bundled
|
||||
/// Source Sans 3 Regular with its `name` and `cmap` tables replaced. Stands in for a font that
|
||||
/// isn't installed (a system CJK font on a machine without it). `None` only if the bundled font
|
||||
/// can't be read.
|
||||
pub fn font_with(family: &str, chars: &[char]) -> Option<Vec<u8>> {
|
||||
font_with_glyph(family, chars, 'X')
|
||||
}
|
||||
|
||||
/// [`font_with`], drawing every character as Source Sans 3's `glyph` (two fonts of one name that
|
||||
/// set the same text differently).
|
||||
pub fn font_with_glyph(family: &str, chars: &[char], glyph: char) -> Option<Vec<u8>> {
|
||||
let glyphs: Vec<(char, char)> = chars.iter().map(|c| (*c, glyph)).collect();
|
||||
font_mapping(family, &glyphs)
|
||||
}
|
||||
|
||||
/// [`font_with`], mapping each character of `glyphs` to Source Sans 3's glyph for the character
|
||||
/// paired with it (`('一', 'X')` draws 一 as X), so characters can have glyphs (and metrics) of
|
||||
/// their own. `None` if the bundled font can't be read or lacks a glyph asked for.
|
||||
pub fn font_mapping(family: &str, glyphs: &[(char, char)]) -> Option<Vec<u8>> {
|
||||
let base = *crate::bundled().first()?;
|
||||
let base_cmap = skrifa::FontRef::new(base).ok()?.charmap();
|
||||
let mut map: Vec<(u32, u32)> = glyphs.iter().map(|(c, g)| Some((*c as u32, base_cmap.map(*g)?.to_u32()))).collect::<Option<_>>()?;
|
||||
map.sort_unstable();
|
||||
map.dedup_by_key(|m| m.0);
|
||||
let mut tables = tables_of(base)?;
|
||||
tables.retain(|(tag, _)| tag != b"name" && tag != b"cmap");
|
||||
|
||||
// `name`: format 0, family (1) and subfamily (2), Windows Unicode English.
|
||||
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect();
|
||||
let mut name = Vec::new();
|
||||
for v in [0u16, 2, 6 + 2 * 12] {
|
||||
name.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut at = 0usize;
|
||||
for (id, s) in [1u16, 2].iter().zip(&strings) {
|
||||
for v in [3u16, 1, 0x409, *id, u16::try_from(s.len()).ok()?, u16::try_from(at).ok()?] {
|
||||
name.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
at += s.len();
|
||||
}
|
||||
strings.iter().for_each(|s| name.extend_from_slice(s));
|
||||
tables.push((*b"name", name));
|
||||
|
||||
// `cmap`: one format 12 subtable (Windows, full Unicode), one group per character.
|
||||
let sub_len = u32::try_from(16 + 12 * map.len()).ok()?;
|
||||
let mut cmap = Vec::new();
|
||||
for v in [0u16, 1, 3, 10] {
|
||||
cmap.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
cmap.extend_from_slice(&12u32.to_be_bytes());
|
||||
cmap.extend_from_slice(&12u16.to_be_bytes());
|
||||
cmap.extend_from_slice(&0u16.to_be_bytes());
|
||||
for v in [sub_len, 0, u32::try_from(map.len()).ok()?] {
|
||||
cmap.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
for (cp, gid) in map {
|
||||
for v in [cp, cp, gid] {
|
||||
cmap.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
tables.push((*b"cmap", cmap));
|
||||
assemble(base, tables)
|
||||
}
|
||||
|
||||
/// Every table of the font file `font`: (tag, bytes).
|
||||
fn tables_of(font: &[u8]) -> Option<Vec<([u8; 4], Vec<u8>)>> {
|
||||
let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes);
|
||||
let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes);
|
||||
let mut tables = Vec::new();
|
||||
for r in 0..usize::from(be16(font, 4)?) {
|
||||
let rec = 12 + r * 16;
|
||||
let tag: [u8; 4] = font.get(rec..rec + 4)?.try_into().ok()?;
|
||||
let (offset, len) = (be32(font, rec + 8)? as usize, be32(font, rec + 12)? as usize);
|
||||
tables.push((tag, font.get(offset..offset.checked_add(len)?)?.to_vec()));
|
||||
}
|
||||
Some(tables)
|
||||
}
|
||||
|
||||
/// A font file of `tables`, with the sfnt version of `font`: the table directory (sorted by tag),
|
||||
/// then the tables, each 4-byte aligned.
|
||||
fn assemble(font: &[u8], mut tables: Vec<([u8; 4], Vec<u8>)>) -> Option<Vec<u8>> {
|
||||
tables.sort_by_key(|t| t.0);
|
||||
let n = u16::try_from(tables.len()).ok()?;
|
||||
let pow = 1u16 << (15 - n.leading_zeros().min(15));
|
||||
let mut out = font.get(0..4)?.to_vec();
|
||||
for v in [n, pow * 16, pow.trailing_zeros() as u16, n * 16 - pow * 16] {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
for (tag, data) in &tables {
|
||||
out.extend_from_slice(tag);
|
||||
out.extend_from_slice(&0u32.to_be_bytes());
|
||||
out.extend_from_slice(&u32::try_from(offset).ok()?.to_be_bytes());
|
||||
out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_be_bytes());
|
||||
offset += data.len().next_multiple_of(4);
|
||||
}
|
||||
for (_, data) in &tables {
|
||||
out.extend_from_slice(data);
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `font` with `tables` added, each replacing the table of its tag. `None` if `font` can't be read.
|
||||
fn with_tables(font: &[u8], tables: Vec<([u8; 4], Vec<u8>)>) -> Option<Vec<u8>> {
|
||||
let mut all = tables_of(font)?;
|
||||
all.retain(|(tag, _)| !tables.iter().any(|(t, _)| t == tag));
|
||||
all.extend(tables);
|
||||
assemble(font, all)
|
||||
}
|
||||
|
||||
/// `font` with vertical metrics: a `vhea` table and a `vmtx` table that give every glyph `default`
|
||||
/// (advance down the line, top side bearing), except the glyphs `overrides` lists (glyph id,
|
||||
/// advance, top side bearing). Every glyph has a long metric. `None` if `font` can't be read.
|
||||
pub fn with_vmtx(font: &[u8], default: (u16, i16), overrides: &[(u16, u16, i16)]) -> Option<Vec<u8>> {
|
||||
use skrifa::raw::TableProvider;
|
||||
let glyphs = skrifa::FontRef::new(font).ok()?.maxp().ok()?.num_glyphs();
|
||||
let mut vmtx = Vec::with_capacity(usize::from(glyphs) * 4);
|
||||
let mut max_advance = 0;
|
||||
for gid in 0..glyphs {
|
||||
let (advance, tsb) = overrides.iter().find(|o| o.0 == gid).map_or(default, |o| (o.1, o.2));
|
||||
max_advance = max_advance.max(advance);
|
||||
vmtx.extend_from_slice(&advance.to_be_bytes());
|
||||
vmtx.extend_from_slice(&tsb.to_be_bytes());
|
||||
}
|
||||
// `vhea` 1.1: typo ascender, descender and line gap, advanceHeightMax, minTop/BottomSideBearing,
|
||||
// yMaxExtent, caret slope rise and run, caret offset, four reserved, metricDataFormat and
|
||||
// numOfLongVerMetrics (at byte 34).
|
||||
let mut vhea = 0x0001_1000_u32.to_be_bytes().to_vec();
|
||||
for v in [500i16, -500, 0] {
|
||||
vhea.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
vhea.extend_from_slice(&max_advance.to_be_bytes());
|
||||
for v in [0i16, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0] {
|
||||
vhea.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
vhea.extend_from_slice(&glyphs.to_be_bytes());
|
||||
with_tables(font, vec![(*b"vhea", vhea), (*b"vmtx", vmtx)])
|
||||
}
|
||||
|
||||
/// `font` with a `VORG` table: every glyph's vertical origin is `default` above its baseline,
|
||||
/// except the glyphs `overrides` lists (glyph id, origin). `None` if `font` can't be read.
|
||||
pub fn with_vorg(font: &[u8], default: i16, overrides: &[(u16, i16)]) -> Option<Vec<u8>> {
|
||||
let mut entries = overrides.to_vec();
|
||||
entries.sort_unstable_by_key(|e| e.0);
|
||||
entries.dedup_by_key(|e| e.0);
|
||||
let mut vorg = Vec::new();
|
||||
for v in [1u16, 0] {
|
||||
vorg.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
vorg.extend_from_slice(&default.to_be_bytes());
|
||||
vorg.extend_from_slice(&u16::try_from(entries.len()).ok()?.to_be_bytes());
|
||||
for (gid, y) in entries {
|
||||
vorg.extend_from_slice(&gid.to_be_bytes());
|
||||
vorg.extend_from_slice(&y.to_be_bytes());
|
||||
}
|
||||
with_tables(font, vec![(*b"VORG", vorg)])
|
||||
}
|
||||
|
||||
/// `font` with its OS/2 `fsType` (embedding licence bits) set to `fs_type`. `None` if the font has
|
||||
/// no OS/2 table.
|
||||
pub fn with_fs_type(mut font: Vec<u8>, fs_type: u16) -> Option<Vec<u8>> {
|
||||
let tables = usize::from(u16::from_be_bytes(font.get(4..6)?.try_into().ok()?));
|
||||
let rec = (0..tables).map(|r| 12 + r * 16).find(|rec| font.get(*rec..rec + 4) == Some(b"OS/2"))?;
|
||||
let offset = u32::from_be_bytes(font.get(rec + 8..rec + 12)?.try_into().ok()?) as usize;
|
||||
font.get_mut(offset + 8..offset + 10)?.copy_from_slice(&fs_type.to_be_bytes());
|
||||
Some(font)
|
||||
}
|
||||
|
||||
/// Offset and length of table `tag` in `font`'s table directory, and where its record is.
|
||||
fn table(font: &[u8], tag: &[u8; 4]) -> Option<(usize, usize, usize)> {
|
||||
let tables = usize::from(u16::from_be_bytes(font.get(4..6)?.try_into().ok()?));
|
||||
let rec = (0..tables).map(|r| 12 + r * 16).find(|rec| font.get(*rec..rec + 4) == Some(tag))?;
|
||||
let offset = u32::from_be_bytes(font.get(rec + 8..rec + 12)?.try_into().ok()?) as usize;
|
||||
let len = u32::from_be_bytes(font.get(rec + 12..rec + 16)?.try_into().ok()?) as usize;
|
||||
Some((rec, offset, len))
|
||||
}
|
||||
|
||||
/// `font` with table `tag` cut to `len` bytes (its directory length; the bytes stay). `None` if
|
||||
/// the font has no such table.
|
||||
pub fn with_table_len(mut font: Vec<u8>, tag: &[u8; 4], len: u32) -> Option<Vec<u8>> {
|
||||
let (rec, _, _) = table(&font, tag)?;
|
||||
font.get_mut(rec + 12..rec + 16)?.copy_from_slice(&len.to_be_bytes());
|
||||
Some(font)
|
||||
}
|
||||
|
||||
/// `font` with the big-endian `u16` at byte `at` of table `tag` set to `value`. `None` if the font
|
||||
/// has no such table or it is too short.
|
||||
pub fn with_table_u16(mut font: Vec<u8>, tag: &[u8; 4], at: usize, value: u16) -> Option<Vec<u8>> {
|
||||
let (_, offset, len) = table(&font, tag)?;
|
||||
if at + 2 > len {
|
||||
return None;
|
||||
}
|
||||
font.get_mut(offset + at..offset + at + 2)?.copy_from_slice(&value.to_be_bytes());
|
||||
Some(font)
|
||||
}
|
||||
|
||||
/// `font` (a TrueType font) with an empty `.notdef`: glyph 0's `loca` entry starts where it ends,
|
||||
/// so the glyph has no outline. `None` if the font has no `head`/`loca` tables.
|
||||
pub fn with_empty_notdef(mut font: Vec<u8>) -> Option<Vec<u8>> {
|
||||
let (_, head, _) = table(&font, b"head")?;
|
||||
let long = font.get(head + 50..head + 52)? != [0, 0];
|
||||
let (_, loca, _) = table(&font, b"loca")?;
|
||||
let width = if long { 4 } else { 2 };
|
||||
let end = font.get(loca + width..loca + 2 * width)?.to_vec();
|
||||
font.get_mut(loca..loca + width)?.copy_from_slice(&end);
|
||||
Some(font)
|
||||
}
|
||||
|
||||
/// `font` with its `name` table replaced by `records`: (platform, encoding, language, name id,
|
||||
/// text). Text is written as UTF-16BE on the Unicode and Windows platforms, as its bytes on the
|
||||
/// Macintosh platform. `None` if `font` can't be read.
|
||||
pub fn with_names(font: &[u8], records: &[(u16, u16, u16, u16, &str)]) -> Option<Vec<u8>> {
|
||||
let strings: Vec<Vec<u8>> =
|
||||
records.iter().map(|r| if r.0 == 1 { r.4.as_bytes().to_vec() } else { r.4.encode_utf16().flat_map(u16::to_be_bytes).collect() }).collect();
|
||||
let count = u16::try_from(records.len()).ok()?;
|
||||
let mut name = Vec::new();
|
||||
for v in [0u16, count, 6 + 12 * count] {
|
||||
name.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut at = 0usize;
|
||||
for (r, s) in records.iter().zip(&strings) {
|
||||
for v in [r.0, r.1, r.2, r.3, u16::try_from(s.len()).ok()?, u16::try_from(at).ok()?] {
|
||||
name.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
at += s.len();
|
||||
}
|
||||
strings.iter().for_each(|s| name.extend_from_slice(s));
|
||||
with_tables(font, vec![(*b"name", name)])
|
||||
}
|
||||
|
||||
/// `font` with its OS/2 `ulCodePageRange1` and `ulCodePageRange2` set (the font's OS/2 table must
|
||||
/// be version 1 or later). `None` if it has none.
|
||||
pub fn with_code_pages(font: Vec<u8>, range1: u32, range2: u32) -> Option<Vec<u8>> {
|
||||
let font = with_table_u16(font, b"OS/2", 78, (range1 >> 16) as u16)?;
|
||||
let font = with_table_u16(font, b"OS/2", 80, range1 as u16)?;
|
||||
let font = with_table_u16(font, b"OS/2", 82, (range2 >> 16) as u16)?;
|
||||
with_table_u16(font, b"OS/2", 84, range2 as u16)
|
||||
}
|
||||
|
||||
/// `font` with a `meta` table holding `dlng` and `slng` script and language tag lists (as written,
|
||||
/// comma-separated), each when given. `None` if `font` can't be read.
|
||||
pub fn with_meta(font: &[u8], dlng: Option<&str>, slng: Option<&str>) -> Option<Vec<u8>> {
|
||||
let maps: Vec<(&[u8; 4], &str)> = [(b"dlng", dlng), (b"slng", slng)].into_iter().filter_map(|(t, v)| Some((t, v?))).collect();
|
||||
let count = u32::try_from(maps.len()).ok()?;
|
||||
let mut meta = Vec::new();
|
||||
for v in [1u32, 0, 0, count] {
|
||||
meta.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut at = 16 + 12 * maps.len();
|
||||
for (tag, v) in &maps {
|
||||
meta.extend_from_slice(*tag);
|
||||
meta.extend_from_slice(&u32::try_from(at).ok()?.to_be_bytes());
|
||||
meta.extend_from_slice(&u32::try_from(v.len()).ok()?.to_be_bytes());
|
||||
at += v.len();
|
||||
}
|
||||
maps.iter().for_each(|(_, v)| meta.extend_from_slice(v.as_bytes()));
|
||||
with_tables(font, vec![(*b"meta", meta)])
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
//! Document fonts: the fonts in a `Document Fonts` folder beside a document, loaded for that
|
||||
//! document alone (its scope) ahead of the installed fonts of the same name; damaged, oversized
|
||||
//! and surplus files are skipped.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
use crate::testing::{font_with, with_fs_type};
|
||||
|
||||
/// An empty temporary folder unique to `name` and this process.
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("dc-docfonts-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_font(dir: &Path, file: &str, family: &str, chars: &[char]) -> PathBuf {
|
||||
let path = dir.join(file);
|
||||
std::fs::write(&path, font_with(family, chars).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_fonts_load_ahead_of_installed_ones() {
|
||||
let root = temp_dir("precedence");
|
||||
let system = root.join("system");
|
||||
let folder = root.join(DOCUMENT_FONTS_FOLDER);
|
||||
std::fs::create_dir_all(&system).unwrap();
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
write_font(&system, "installed.ttf", "DocFont Precedence", &['a']);
|
||||
let file = write_font(&folder, "doc.ttf", "DocFont Precedence", &['b']);
|
||||
|
||||
let db = FontDb::with_font_dirs(vec![system]);
|
||||
// The installed face is loaded first (the document was composed before its folder appeared).
|
||||
let installed = db.face("DocFont Precedence", "Regular");
|
||||
assert!(installed.covers('a') && matches!(installed.source, FontSource::Installed(_)), "{:?}", installed.source);
|
||||
|
||||
let r = db.load_document_fonts(&folder);
|
||||
assert_eq!((r.faces, r.skipped.len()), (1, 0), "{:?}", r.skipped);
|
||||
let face = db.scoped(r.scope).face("DocFont Precedence", "Regular");
|
||||
assert!(face.covers('b'), "the document's face wins");
|
||||
assert_eq!(face.source, FontSource::Document(file.clone()));
|
||||
assert_eq!(face.source.path(), Some(file.as_path()));
|
||||
assert!(db.face("DocFont Precedence", "Regular").covers('a'), "outside the document the installed face stays");
|
||||
|
||||
// Loading the folder again (another document beside it) shares the face, in a scope of its own.
|
||||
let again = db.load_document_fonts(&folder);
|
||||
assert_eq!((again.faces, again.skipped.len()), (1, 0));
|
||||
assert_ne!(again.scope, r.scope);
|
||||
assert!(Arc::ptr_eq(&db.scoped(again.scope).face("DocFont Precedence", "Regular"), &face));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_oversized_and_other_files_are_skipped() {
|
||||
let folder = temp_dir("skipped");
|
||||
write_font(&folder, "good.otf", "DocFont Good", &['g']);
|
||||
std::fs::write(folder.join("broken.ttf"), b"not a font at all").unwrap();
|
||||
// Sparse: takes no disk space, but its length is over the limit.
|
||||
std::fs::File::create(folder.join("huge.ttc")).unwrap().set_len(MAX_DOCUMENT_FONT_BYTES + 1).unwrap();
|
||||
std::fs::write(folder.join("readme.txt"), b"fonts for the brochure").unwrap();
|
||||
std::fs::create_dir_all(folder.join("nested.ttf")).unwrap();
|
||||
write_font(&folder.join("nested.ttf"), "inner.ttf", "DocFont Nested", &['n']);
|
||||
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let r = db.load_document_fonts(&folder);
|
||||
assert_eq!(r.faces, 1);
|
||||
assert!(db.scoped(r.scope).has_family("DocFont Good"));
|
||||
assert!(!db.has_family("DocFont Good"), "the document's alone");
|
||||
assert!(!db.scoped(r.scope).has_family("DocFont Nested"), "subfolders aren't read");
|
||||
assert_eq!(r.skipped.len(), 2, "{:?}", r.skipped);
|
||||
assert!(r.skipped.iter().any(|s| s.contains("broken.ttf")), "{:?}", r.skipped);
|
||||
assert!(r.skipped.iter().any(|s| s.contains("huge.ttc") && s.contains(&format!("{} MB", MAX_DOCUMENT_FONT_BYTES >> 20))), "{:?}", r.skipped);
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_number_of_files_read_is_capped() {
|
||||
let folder = temp_dir("cap");
|
||||
for i in 0..MAX_DOCUMENT_FONT_FILES + 5 {
|
||||
std::fs::write(folder.join(format!("{i:04}.ttf")), b"junk").unwrap();
|
||||
}
|
||||
// Sorts after every junk file, so it is past the cap.
|
||||
write_font(&folder, "zz-real.ttf", "DocFont Past Cap", &['z']);
|
||||
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let r = db.load_document_fonts(&folder);
|
||||
assert_eq!((r.faces, r.scope), (0, 0));
|
||||
assert!(!db.scoped(r.scope).has_family("DocFont Past Cap"));
|
||||
assert_eq!(r.skipped.len(), MAX_DOCUMENT_FONT_FILES + 1, "one note for each file read, one for the rest");
|
||||
assert!(r.skipped.last().is_some_and(|s| s.contains("6 more") && s.contains("200")), "{:?}", r.skipped.last());
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_cjk_collections_are_within_the_limit() {
|
||||
// macOS's Songti collection is 67 MB; full Noto/Source Han CJK collections are larger.
|
||||
let folder = temp_dir("cjk-size");
|
||||
std::fs::File::create(folder.join("cjk.ttc")).unwrap().set_len(200 << 20).unwrap();
|
||||
let r = FontDb::with_font_dirs(vec![]).load_document_fonts(&folder);
|
||||
assert_eq!(r.skipped.len(), 1, "{:?}", r.skipped);
|
||||
assert!(!r.skipped[0].contains("larger than"), "skipped for not being a font, not for its size: {:?}", r.skipped);
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_folder_loads_nothing() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER));
|
||||
assert_eq!((r.faces, r.scope, r.skipped.len()), (0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_scope_sees_its_own_fonts_only() {
|
||||
const FAMILY: &str = "DocFont Scope";
|
||||
let (a, b) = (temp_dir("scope-a"), temp_dir("scope-b"));
|
||||
write_font(&a, "a.ttf", FAMILY, &['a']);
|
||||
write_font(&b, "b.ttf", FAMILY, &['b', '\u{E123}']);
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let (ra, rb) = (db.load_document_fonts(&a), db.load_document_fonts(&b));
|
||||
let (in_a, in_b) = (db.scoped(ra.scope), db.scoped(rb.scope));
|
||||
|
||||
assert!(in_a.face(FAMILY, "Regular").covers('a') && in_b.face(FAMILY, "Regular").covers('b'));
|
||||
assert!(in_a.families().iter().any(|f| f == FAMILY) && in_a.styles(FAMILY) == ["Regular"]);
|
||||
assert!(!db.has_family(FAMILY) && !db.families().iter().any(|f| f == FAMILY), "no document's fonts are shared");
|
||||
assert!(!db.scoped(0).has_family(FAMILY));
|
||||
// Fallback finds a character in the document's own fonts, never another document's.
|
||||
let exclude = in_a.face(FALLBACK_FAMILY, "Regular").id();
|
||||
assert!(in_a.fallback_for('\u{E123}', exclude, None).is_none());
|
||||
assert!(in_b.fallback_for('\u{E123}', exclude, None).is_some_and(|f| f.family == FAMILY));
|
||||
let _ = std::fs::remove_dir_all(&a);
|
||||
let _ = std::fs::remove_dir_all(&b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_scope_finds_nothing_and_reopening_reuses_the_faces() {
|
||||
const FAMILY: &str = "DocFont Reopened";
|
||||
let folder = temp_dir("reopen");
|
||||
let file = write_font(&folder, "r.ttf", FAMILY, &['r']);
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let r = db.load_document_fonts(&folder);
|
||||
let face = db.scoped(r.scope).face(FAMILY, "Regular");
|
||||
db.close_scope(r.scope);
|
||||
assert!(!db.scoped(r.scope).has_family(FAMILY), "closed");
|
||||
|
||||
let again = db.load_document_fonts(&folder);
|
||||
assert!(Arc::ptr_eq(&db.scoped(again.scope).face(FAMILY, "Regular"), &face), "an unchanged file isn't read again");
|
||||
// A file changed since is read again.
|
||||
std::fs::write(&file, font_with(FAMILY, &['r', 's']).unwrap()).unwrap();
|
||||
let changed = db.load_document_fonts(&folder);
|
||||
let new_face = db.scoped(changed.scope).face(FAMILY, "Regular");
|
||||
assert!(new_face.covers('s') && new_face.id() != face.id());
|
||||
assert!(!db.scoped(again.scope).face(FAMILY, "Regular").covers('s'), "the document open before keeps what it opened with");
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_licence_reads_the_embedding_bits() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
for (fs_type, restricted) in [(0x0000, false), (0x0002, true), (0x0006, false), (0x000A, false), (0x0008, false), (0x0302, true)] {
|
||||
let family = format!("DocFont Licence {fs_type:04x}");
|
||||
assert_eq!(db.add_font(with_fs_type(font_with(&family, &['l']).unwrap(), fs_type).unwrap()), 1);
|
||||
assert_eq!(db.face(&family, "Regular").restricted_licence(), restricted, "fsType {fs_type:#06x}");
|
||||
}
|
||||
assert!(!db.face(FALLBACK_FAMILY, "Regular").restricted_licence(), "the bundled fonts are OFL");
|
||||
assert_eq!(db.face(FALLBACK_FAMILY, "Regular").source, FontSource::Bundled);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
//! CJK fallback follows the character's language (and the script where it is unambiguous).
|
||||
|
||||
use super::*;
|
||||
use crate::testing::font_with;
|
||||
|
||||
const HAN: char = '直';
|
||||
const HANGUL: char = '한';
|
||||
const KANA: char = 'あ';
|
||||
const BOPOMOFO: char = 'ㄅ';
|
||||
|
||||
/// A database of the bundled fonts (and the craft-fonts Japanese faces, when built with them)
|
||||
/// only, no system fonts, plus `fonts` as (family, chars).
|
||||
fn db_with(fonts: &[(&str, &[char])]) -> FontDb {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
for (family, chars) in fonts {
|
||||
assert_eq!(db.add_font(font_with(family, chars).unwrap()), 1, "{family}");
|
||||
}
|
||||
db
|
||||
}
|
||||
|
||||
fn family(db: &FontDb, c: char, language: Option<&str>) -> Option<String> {
|
||||
db.fallback_for(c, 0, language).map(|f| f.family.clone())
|
||||
}
|
||||
|
||||
/// The families the Japanese chain finds, in order: the craft-fonts Japanese faces (none without
|
||||
/// craft-fonts), then `installed`, the stand-ins for system fonts (in chain order).
|
||||
fn japanese_chain_with(installed: &[&'static str]) -> Vec<&'static str> {
|
||||
let mut v: Vec<&'static str> = Vec::new();
|
||||
for f in crate::japanese_document_fonts() {
|
||||
if !v.contains(&f.family) {
|
||||
v.push(f.family);
|
||||
}
|
||||
}
|
||||
v.extend_from_slice(installed);
|
||||
v
|
||||
}
|
||||
|
||||
/// What the language-blind search finds first for CJK text when `added` is the first added font
|
||||
/// covering it: the craft-fonts Japanese faces load ahead of added fonts.
|
||||
fn first_loaded(added: &'static str) -> &'static str {
|
||||
japanese_chain_with(&[added])[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ideographs_follow_the_language() {
|
||||
let db = db_with(&[("Songti SC", &[HAN]), ("Songti TC", &[HAN]), ("AppleMyungjo", &[HAN]), ("Hiragino Mincho ProN", &[HAN])]);
|
||||
assert_eq!(family(&db, HAN, Some("zh-Hans")).as_deref(), Some("Songti SC"));
|
||||
assert_eq!(family(&db, HAN, Some("zh")).as_deref(), Some("Songti SC"), "plain Chinese is Simplified");
|
||||
assert_eq!(family(&db, HAN, Some("zh-Hant")).as_deref(), Some("Songti TC"));
|
||||
assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some("AppleMyungjo"));
|
||||
assert_eq!(family(&db, HAN, Some("ko-KR")).as_deref(), Some("AppleMyungjo"), "the language decides, whatever the region");
|
||||
let ja = japanese_chain_with(&["Hiragino Mincho ProN"])[0];
|
||||
assert_eq!(family(&db, HAN, Some("ja")).as_deref(), Some(ja), "the craft-fonts faces lead the Japanese chain, then system fonts");
|
||||
assert_eq!(family(&db, HAN, Some("ja-JP")).as_deref(), Some(ja));
|
||||
// CJK punctuation follows the language too.
|
||||
let db = db_with(&[("Other CJK", &['、']), ("Songti SC", &['、'])]);
|
||||
assert_eq!(family(&db, '、', Some("zh-Hans")).as_deref(), Some("Songti SC"));
|
||||
assert_eq!(family(&db, '、', None).as_deref(), Some(first_loaded("Other CJK")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ideographs_without_a_cjk_language_fall_back_as_before() {
|
||||
let db = db_with(&[("Other CJK", &[HAN]), ("Songti SC", &[HAN]), ("AppleMyungjo", &[HAN])]);
|
||||
for language in [None, Some("en-US"), Some("tr"), Some("not a tag")] {
|
||||
assert_eq!(family(&db, HAN, language).as_deref(), Some(first_loaded("Other CJK")), "{language:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_decides_where_it_is_unambiguous() {
|
||||
// A non-chain font loaded first would win the language-blind search.
|
||||
let db = db_with(&[
|
||||
("Other CJK", &[HANGUL, KANA, BOPOMOFO]),
|
||||
("AppleMyungjo", &[HANGUL]),
|
||||
("Songti TC", &[BOPOMOFO]),
|
||||
("Yu Mincho", &[KANA]),
|
||||
("Hiragino Mincho ProN", &[KANA]),
|
||||
]);
|
||||
for language in [None, Some("en-US"), Some("ja"), Some("zh-Hans")] {
|
||||
assert_eq!(family(&db, HANGUL, language).as_deref(), Some("AppleMyungjo"), "Hangul is Korean: {language:?}");
|
||||
assert_eq!(family(&db, BOPOMOFO, language).as_deref(), Some("Songti TC"), "Bopomofo is Traditional Chinese: {language:?}");
|
||||
}
|
||||
// Hiragino Mincho ProN comes before Yu Mincho in the chain, whatever order they loaded in.
|
||||
let chain = japanese_chain_with(&["Hiragino Mincho ProN", "Yu Mincho"]);
|
||||
for language in [None, Some("zh-Hans"), Some("ko")] {
|
||||
assert_eq!(family(&db, KANA, language).as_deref(), Some(chain[0]), "kana is Japanese: {language:?}");
|
||||
}
|
||||
// Past the faces the chain can't use (here its first, as the primary font), the next.
|
||||
let first = db.face(chain[0], "Regular");
|
||||
assert_eq!(db.fallback_for(KANA, first.id(), None).map(|f| f.family.clone()).as_deref(), Some(chain[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_chain_font_the_fallback_is_unchanged() {
|
||||
let db = db_with(&[("Other CJK", &[HANGUL])]);
|
||||
// Without craft-fonts nothing covers the ideograph; with it, its first Japanese face does.
|
||||
let craft = japanese_chain_with(&[]).first().copied();
|
||||
for language in ["zh-Hans", "zh-Hant", "ko", "ja"] {
|
||||
assert_eq!(family(&db, HAN, Some(language)).as_deref(), craft, "{language}");
|
||||
}
|
||||
assert_eq!(family(&db, HANGUL, Some("ko")).as_deref(), Some("Other CJK"));
|
||||
assert_eq!(family(&db, BOPOMOFO, Some("zh-Hant")), None, "nothing covers it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_fonts_load_from_the_font_folders_when_first_needed() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cjk-fallback-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("songti.ttf"), font_with("Songti SC", &[HAN]).unwrap()).unwrap();
|
||||
std::fs::write(dir.join("myungjo.ttf"), font_with("AppleMyungjo", &[HAN]).unwrap()).unwrap();
|
||||
// An added font covers the ideograph, so the language-blind search never scans the folder.
|
||||
let other = || font_with("Other CJK", &[HAN]).unwrap();
|
||||
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
db.add_font(other());
|
||||
assert_eq!(family(&db, HAN, None).as_deref(), Some(first_loaded("Other CJK")));
|
||||
assert!(!db.is_loaded("Songti SC"), "no language, no chain font loaded");
|
||||
assert_eq!(family(&db, HAN, Some("zh-Hans")).as_deref(), Some("Songti SC"));
|
||||
assert!(!db.is_loaded("AppleMyungjo"), "only what the chain needed");
|
||||
assert_eq!(family(&db, HAN, None).as_deref(), Some(first_loaded("Other CJK")), "once loaded, still not used without the language");
|
||||
|
||||
// With the system fallback off, installed chain fonts aren't loaded.
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
db.add_font(other());
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some(first_loaded("Other CJK")));
|
||||
assert!(!db.is_loaded("AppleMyungjo"));
|
||||
}
|
||||
|
|
@ -129,8 +129,38 @@ fn a_link_back_to_a_parent_folder_ends_the_scan() {
|
|||
#[test]
|
||||
fn the_scan_reads_names_without_loading_the_font() {
|
||||
let dir = font_dir("names");
|
||||
assert_eq!(file_face_names(&dir.join("Sysfont-Regular.ttf")), [(FAMILY.to_string(), "Regular".to_string())]);
|
||||
let names: Vec<(String, String)> = file_face_names(&dir.join("Sysfont-Regular.ttf")).into_iter().map(|f| (f.family, f.style)).collect();
|
||||
assert_eq!(names, [(FAMILY.to_string(), "Regular".to_string())]);
|
||||
assert!(file_face_names(&dir.join("damaged.ttf")).is_empty());
|
||||
assert!(file_face_names(&dir.join("readme.txt")).is_empty());
|
||||
assert!(file_face_names(&dir.join("missing.ttf")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_menus_group_installed_and_loaded_fonts_without_loading_them() {
|
||||
use crate::testing::{font_with, with_code_pages, with_names};
|
||||
use crate::{FamilyInfo, FontGroup};
|
||||
let dir = std::env::temp_dir().join(format!("dc-sysfonts-groups-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let names = |family: &str, lang: u16, native: &str| {
|
||||
let font = font_with(family, &['a']).unwrap();
|
||||
with_names(&font, &[(3, 1, 0x409, 1, family), (3, 1, 0x409, 2, "Regular"), (3, 1, lang, 1, native)]).unwrap()
|
||||
};
|
||||
std::fs::write(dir.join("jp.ttf"), names("Groups Mincho", 0x0411, "グループ明朝")).unwrap();
|
||||
// Declares nothing (no code pages): its kana make it Japanese, read from the cmap.
|
||||
std::fs::write(dir.join("kana.otf"), with_code_pages(font_with("Groups Kana", &['a', 'あ']).unwrap(), 0, 0).unwrap()).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
db.set_system_fallback(false);
|
||||
db.add_font(with_code_pages(font_with("Groups Ming", &['a']).unwrap(), 1 << 20, 0).unwrap());
|
||||
let infos = db.scoped(0).family_infos();
|
||||
let find = |f: &str| infos.iter().find(|i| i.family == f).cloned().unwrap_or_else(|| panic!("{f} in {infos:?}"));
|
||||
assert_eq!(find("Groups Mincho"), FamilyInfo { family: "Groups Mincho".into(), group: FontGroup::Japanese, native: Some("グループ明朝".into()) });
|
||||
assert_eq!(find("Groups Kana").group, FontGroup::Japanese);
|
||||
assert_eq!(find("Groups Ming").group, FontGroup::ChineseTraditional);
|
||||
assert_eq!(find(crate::DEFAULT_FAMILY).group, FontGroup::Western);
|
||||
assert!(!db.is_loaded("Groups Mincho"), "the scan read its tables, not the font");
|
||||
// Families are listed once.
|
||||
assert_eq!(infos.iter().filter(|i| i.family == "Groups Mincho").count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
//! Vertical metrics (`vmtx`, `vhea`, `VORG`), the ideographic em box and top-to-bottom shaping.
|
||||
//!
|
||||
//! The synthetic font (Source Sans 3 with `vhea`/`vmtx`, and `VORG` where asked) runs everywhere;
|
||||
//! the checks against Shippori Mincho's own tables run when built with craft-fonts.
|
||||
|
||||
use skrifa::raw::TableProvider;
|
||||
|
||||
use super::*;
|
||||
use crate::testing::{font_mapping, font_with, with_table_len, with_table_u16, with_vmtx, with_vorg};
|
||||
use crate::{ShapeContext, feature, shape_vertical, shape_with_context};
|
||||
|
||||
/// Text in Japanese.
|
||||
fn japanese() -> ShapeContext<'static> {
|
||||
ShapeContext { language: Some("ja"), ..Default::default() }
|
||||
}
|
||||
|
||||
/// The synthetic font's characters and the Source Sans 3 glyphs that draw them.
|
||||
const GLYPHS: &[(char, char)] = &[('一', 'X'), ('ヸ', 'O'), ('、', ',')];
|
||||
|
||||
/// A font with vertical metrics: every glyph one em down the line with a top side bearing of 120,
|
||||
/// except ヸ (drawn as O): 1.024 em, top side bearing 100.
|
||||
fn vertical_font() -> Vec<u8> {
|
||||
let base = font_mapping("Vertical", GLYPHS).unwrap();
|
||||
let wi = gid_in(&base, 'ヸ');
|
||||
with_vmtx(&base, (1000, 120), &[(wi, 1024, 100)]).unwrap()
|
||||
}
|
||||
|
||||
fn gid_in(font: &[u8], c: char) -> u16 {
|
||||
let gid = skrifa::FontRef::new(font).unwrap().charmap().map(c).unwrap().to_u32();
|
||||
u16::try_from(gid).unwrap()
|
||||
}
|
||||
|
||||
/// Shippori Mincho from craft-fonts, or `None` (with a note) when built without it.
|
||||
fn shippori() -> Option<Arc<FontFace>> {
|
||||
if !crate::japanese_fonts().any(|f| f.family == "Shippori Mincho") {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
|
||||
return None;
|
||||
}
|
||||
let face = FontDb::global().face("Shippori Mincho", "Regular");
|
||||
assert_eq!(face.family, "Shippori Mincho");
|
||||
Some(face)
|
||||
}
|
||||
|
||||
fn face_of(bytes: Vec<u8>) -> FontFace {
|
||||
make_face(FontBytes::Owned(Arc::new(bytes)), FontSource::Memory, 0, "Test".into(), "Regular".into(), Vec::new()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_vmtx() {
|
||||
let face = face_of(vertical_font());
|
||||
let font = face.skrifa().unwrap();
|
||||
let gm = font.glyph_metrics(Size::unscaled(), LocationRef::default());
|
||||
for (c, advance, tsb) in [('一', 1000.0, 120.0), ('、', 1000.0, 120.0), ('ヸ', 1024.0, 100.0)] {
|
||||
let gid = face.glyph_for(c);
|
||||
assert_ne!(gid, 0, "{c}");
|
||||
assert_eq!(face.v_advance(gid), advance, "{c}");
|
||||
// No VORG (TrueType outlines): the origin is the glyph's top plus its top side bearing.
|
||||
let top = f64::from(gm.bounds(GlyphId::new(gid)).unwrap().y_max);
|
||||
assert_eq!(face.v_origin(gid), top + tsb, "{c}");
|
||||
}
|
||||
// The em box doesn't come from vmtx: Source Sans 3's BASE table.
|
||||
assert_eq!(face.em_box(), (830.0, -170.0));
|
||||
// Glyphs past the long metrics advance as the last of them.
|
||||
let face = face_of(with_table_u16(vertical_font(), b"vhea", 34, 1).unwrap());
|
||||
assert_eq!(face.v_advance(face.glyph_for('ヸ')), 1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vorg_gives_the_vertical_origin() {
|
||||
let wi = gid_in(&vertical_font(), 'ヸ');
|
||||
let face = face_of(with_vorg(&vertical_font(), 880, &[(wi, 900)]).unwrap());
|
||||
assert_eq!(face.v_origin(face.glyph_for('一')), 880.0);
|
||||
assert_eq!(face.v_origin(u32::from(wi)), 900.0);
|
||||
assert_eq!(face.v_advance(u32::from(wi)), 1024.0, "advances still come from vmtx");
|
||||
// VORG without vmtx: origins from VORG, one em down the line.
|
||||
let base = font_mapping("Vorg Only", GLYPHS).unwrap();
|
||||
let face = face_of(with_vorg(&base, 870, &[]).unwrap());
|
||||
let ichi = face.glyph_for('一');
|
||||
assert_eq!((face.v_advance(ichi), face.v_origin(ichi)), (1000.0, 870.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shippori_reads_vmtx() {
|
||||
let Some(face) = shippori() else { return };
|
||||
let font = face.skrifa().unwrap();
|
||||
let vmtx = font.vmtx().unwrap();
|
||||
let gm = font.glyph_metrics(Size::unscaled(), LocationRef::default());
|
||||
for c in ['「', '、', '一', 'ヸ'] {
|
||||
let gid = face.glyph_for(c);
|
||||
let g = GlyphId::new(gid);
|
||||
assert_eq!(face.v_advance(gid), f64::from(vmtx.advance(g).unwrap()), "{c}");
|
||||
// No VORG (TrueType outlines): the origin is the glyph's top plus its top side bearing.
|
||||
let top = f64::from(gm.bounds(g).unwrap().y_max) + f64::from(vmtx.side_bearing(g).unwrap());
|
||||
assert_eq!(face.v_origin(gid), top, "{c}");
|
||||
}
|
||||
// No BASE table: the em box is the OS/2 typo ascender and descender (exactly one em here).
|
||||
assert_eq!(face.em_box(), (880.0, -120.0));
|
||||
// Ideographs fill the em box; ヸ is taller than it is wide.
|
||||
let ichi = face.glyph_for('一');
|
||||
assert_eq!((face.v_advance(ichi), face.v_origin(ichi)), (1000.0, 880.0));
|
||||
let wi = face.glyph_for('ヸ');
|
||||
assert_eq!((face.advance(wi), face.v_advance(wi)), (1000.0, 1024.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_without_vmtx_use_the_em_box() {
|
||||
// Source Sans 3 has no vmtx; its BASE table gives the ideographic em box bottom (-170).
|
||||
let face = face_of(font_with("No Vmtx", &['一']).unwrap());
|
||||
let gid = face.glyph_for('一');
|
||||
assert_ne!(gid, 0);
|
||||
assert_eq!(face.em_box(), (830.0, -170.0));
|
||||
assert_eq!(face.v_advance(gid), face.upem);
|
||||
assert_eq!(face.v_origin(gid), 830.0);
|
||||
// Without BASE either: the OS/2 typo ascender and descender (1000, -326), centred on one em.
|
||||
let face = face_of(with_table_len(font_with("No Base", &['一']).unwrap(), b"BASE", 0).unwrap());
|
||||
assert_eq!(face.em_box(), (837.0, -163.0));
|
||||
// Without OS/2: the ascender and descender, the same way.
|
||||
let face = face_of(with_table_len(with_table_len(font_with("No OS2", &['一']).unwrap(), b"BASE", 0).unwrap(), b"OS/2", 0).unwrap());
|
||||
let (top, bottom) = face.em_box();
|
||||
assert_eq!(top - bottom, face.upem);
|
||||
assert!((top + bottom - (face.ascent - face.descent)).abs() < 1e-9, "{top} {bottom}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_vertical_tables_do_not_panic() {
|
||||
let mut fonts = vec![("synthetic", vertical_font())];
|
||||
if let Some(face) = shippori() {
|
||||
fonts.push(("Shippori Mincho", face.data().to_vec()));
|
||||
}
|
||||
let vorg = with_vorg(&vertical_font(), 880, &[(1, 900), (2, 910)]).unwrap();
|
||||
let mut cases = vec![
|
||||
("synthetic: VORG cut short".to_string(), with_table_len(vorg.clone(), b"VORG", 6).unwrap()),
|
||||
("synthetic: more origins than VORG holds".to_string(), with_table_u16(vorg, b"VORG", 6, u16::MAX).unwrap()),
|
||||
("BASE cut short".to_string(), with_table_len(crate::bundled()[0].to_vec(), b"BASE", 6).unwrap()),
|
||||
];
|
||||
for (name, font) in fonts {
|
||||
cases.extend([
|
||||
(format!("{name}: vmtx cut short"), with_table_len(font.clone(), b"vmtx", 2).unwrap()),
|
||||
(format!("{name}: vmtx empty"), with_table_len(font.clone(), b"vmtx", 0).unwrap()),
|
||||
(format!("{name}: more metrics than vmtx holds"), with_table_u16(font.clone(), b"vhea", 34, u16::MAX).unwrap()),
|
||||
(format!("{name}: no long metrics"), with_table_u16(font.clone(), b"vhea", 34, 0).unwrap()),
|
||||
(format!("{name}: vhea cut short"), with_table_len(font, b"vhea", 10).unwrap()),
|
||||
]);
|
||||
}
|
||||
for (what, bytes) in cases {
|
||||
let face = face_of(bytes);
|
||||
let (top, bottom) = face.em_box();
|
||||
assert!(top > bottom && (top - bottom - face.upem).abs() < 1e-9, "{what}: {top} {bottom}");
|
||||
for gid in [0, face.glyph_for('一'), face.glyph_for('、'), u32::from(u16::MAX), u32::MAX] {
|
||||
let (adv, origin) = (face.v_advance(gid), face.v_origin(gid));
|
||||
assert!(adv.is_finite() && adv >= 0.0 && origin.is_finite(), "{what}: {gid} {adv} {origin}");
|
||||
}
|
||||
let g = shape_vertical(&face, "一、ま", &[feature("vpal").unwrap()], |c| c, japanese());
|
||||
assert_eq!(g.len(), 3, "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_text_shapes_top_to_bottom() {
|
||||
let face = face_of(vertical_font());
|
||||
let h = shape_with_context(&face, "一ヸ、", &[], |c| c, japanese());
|
||||
let v = shape_vertical(&face, "一ヸ、", &[], |c| c, japanese());
|
||||
// This font has no vertical forms (`vert`): the same glyphs.
|
||||
assert_eq!(v.iter().map(|g| g.gid).collect::<Vec<_>>(), h.iter().map(|g| g.gid).collect::<Vec<_>>());
|
||||
// Advances run down the line, by vmtx; offsets are what GPOS moves a glyph from its vertical
|
||||
// origin (nothing here).
|
||||
let placed: Vec<_> = v.iter().map(|g| (g.x_advance, g.y_advance, g.x_offset, g.y_offset)).collect();
|
||||
assert_eq!(placed, [(0, -1000, 0, 0), (0, -1024, 0, 0), (0, -1000, 0, 0)]);
|
||||
// Origins from VORG leave the offsets alone too.
|
||||
let face = face_of(with_vorg(&vertical_font(), 880, &[(gid_in(&vertical_font(), 'ヸ'), 900)]).unwrap());
|
||||
let v = shape_vertical(&face, "一ヸ", &[], |c| c, ShapeContext::default());
|
||||
assert_eq!(v.iter().map(|g| (g.y_advance, g.x_offset, g.y_offset)).collect::<Vec<_>>(), [(-1000, 0, 0), (-1024, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shippori_takes_vertical_forms_and_proportional_metrics() {
|
||||
let Some(face) = shippori() else { return };
|
||||
let h = shape_with_context(&face, "一、", &[], |c| c, japanese());
|
||||
let v = shape_vertical(&face, "一、", &[], |c| c, japanese());
|
||||
assert_eq!(v[0].gid, h[0].gid);
|
||||
// `vert` without asking: the comma's vertical form.
|
||||
assert_ne!(v[1].gid, h[1].gid);
|
||||
assert_eq!(face.v_origin(v[1].gid), 880.0);
|
||||
// Advances run down the line; offsets are what GPOS moves a glyph from its vertical origin.
|
||||
for g in &v {
|
||||
assert_eq!((g.x_advance, g.y_advance, g.x_offset, g.y_offset), (0, -1000, 0, 0), "{g:?}");
|
||||
}
|
||||
assert_eq!(shape_vertical(&face, "ヸ", &[], |c| c, ShapeContext::default())[0].y_advance, -1024);
|
||||
// `vpal` (proportional vertical metrics) shortens kana and moves them along the line.
|
||||
let ma = shape_vertical(&face, "ま", &[feature("vpal").unwrap()], |c| c, ShapeContext::default())[0];
|
||||
assert_eq!((ma.y_advance, ma.x_offset, ma.y_offset), (-974, 0, 4));
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
//! Vertical metrics for upright glyphs in vertical text: advances down the line (`vmtx`), vertical
|
||||
//! origins (`VORG`, else `vmtx` and the glyph's top) and the ideographic em box (`BASE`, else
|
||||
//! OS/2). Fonts without them fall back to the em box: one em down the line, the origin at its top.
|
||||
|
||||
use skrifa::instance::Size;
|
||||
use skrifa::metrics::GlyphMetrics;
|
||||
use skrifa::raw::TableProvider;
|
||||
use skrifa::raw::tables::{vmtx::Vmtx, vorg::Vorg};
|
||||
use skrifa::raw::types::Tag;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
||||
use crate::FontFace;
|
||||
|
||||
/// A face's vertical metrics tables, read once for a run of lookups.
|
||||
pub(crate) struct VMetrics<'a> {
|
||||
vmtx: Option<Vmtx<'a>>,
|
||||
vorg: Option<Vorg<'a>>,
|
||||
glyphs: Option<GlyphMetrics<'a>>,
|
||||
upem: f64,
|
||||
em_top: f64,
|
||||
}
|
||||
|
||||
impl<'a> VMetrics<'a> {
|
||||
pub(crate) fn new(face: &'a FontFace) -> Self {
|
||||
let font = face.skrifa();
|
||||
VMetrics {
|
||||
vmtx: font.as_ref().and_then(|f| f.vmtx().ok()),
|
||||
vorg: font.as_ref().and_then(|f| f.vorg().ok()),
|
||||
glyphs: font.map(|f| f.glyph_metrics(Size::unscaled(), face.location())),
|
||||
upem: face.upem,
|
||||
em_top: face.em_box().0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance down the line in font units.
|
||||
pub(crate) fn advance(&self, gid: GlyphId) -> f64 {
|
||||
self.vmtx.as_ref().and_then(|t| t.advance(gid)).map_or(self.upem, f64::from)
|
||||
}
|
||||
|
||||
/// Height of the vertical origin above the glyph's baseline in font units.
|
||||
pub(crate) fn origin(&self, gid: GlyphId) -> f64 {
|
||||
if let Some(t) = &self.vorg {
|
||||
return f64::from(t.vertical_origin_y(gid));
|
||||
}
|
||||
// TrueType outlines: the glyph's top plus its top side bearing.
|
||||
if let (Some(t), Some(g)) = (&self.vmtx, &self.glyphs)
|
||||
&& let (Some(tsb), Some(b)) = (t.side_bearing(gid), g.bounds(gid))
|
||||
&& t.advance(gid).is_some()
|
||||
{
|
||||
return f64::from(b.y_max) + f64::from(tsb);
|
||||
}
|
||||
self.em_top
|
||||
}
|
||||
}
|
||||
|
||||
/// The ideographic em box (top, bottom) in font units, y up: `BASE` `idtp`/`ideo`, else the OS/2
|
||||
/// typo ascender and descender, else the ascender and descender; the last two centred on one em.
|
||||
pub(crate) fn em_box(face: &FontFace) -> (f64, f64) {
|
||||
let upem = face.upem;
|
||||
let font = face.skrifa();
|
||||
if let Some(b) = font.as_ref().and_then(|f| base_em_box(f, upem)) {
|
||||
return b;
|
||||
}
|
||||
let centred = |asc: f64, desc: f64| {
|
||||
let span = asc - desc;
|
||||
(span > 0.0 && span.is_finite()).then(|| {
|
||||
let top = asc - (span - upem) / 2.0;
|
||||
(top, top - upem)
|
||||
})
|
||||
};
|
||||
font.and_then(|f| f.os2().ok())
|
||||
.and_then(|t| centred(f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender())))
|
||||
.or_else(|| centred(face.ascent, -face.descent))
|
||||
.unwrap_or((upem * 0.88, upem * -0.12))
|
||||
}
|
||||
|
||||
/// The em box from the `BASE` table's horizontal axis: the Han script's values, else the default
|
||||
/// script's, else the first script's that has them.
|
||||
fn base_em_box(font: &skrifa::FontRef<'_>, upem: f64) -> Option<(f64, f64)> {
|
||||
let axis = font.base().ok()?.horiz_axis()?.ok()?;
|
||||
let tags = axis.base_tag_list()?.ok()?;
|
||||
let index = |tag: &[u8; 4]| tags.baseline_tags().iter().position(|t| t.get() == Tag::new(tag));
|
||||
let (ideo, idtp) = (index(b"ideo"), index(b"idtp"));
|
||||
if ideo.is_none() && idtp.is_none() {
|
||||
return None;
|
||||
}
|
||||
let scripts = axis.base_script_list().ok()?;
|
||||
let records = scripts.base_script_records();
|
||||
let rank = |t: Tag| [Tag::new(b"hani"), Tag::new(b"kana"), Tag::new(b"DFLT")].iter().position(|p| *p == t).unwrap_or(3);
|
||||
let mut order: Vec<_> = records.iter().collect();
|
||||
order.sort_by_key(|r| rank(r.base_script_tag()));
|
||||
order.into_iter().find_map(|r| {
|
||||
let values = r.base_script(scripts.offset_data()).ok()?.base_values()?.ok()?;
|
||||
let coord = |i: Option<usize>| i.and_then(|i| values.base_coords().get(i).ok()).map(|c| f64::from(c.coordinate()));
|
||||
match (coord(ideo), coord(idtp)) {
|
||||
(Some(bottom), Some(top)) => (top > bottom).then_some((top, bottom)),
|
||||
(Some(bottom), None) => Some((bottom + upem, bottom)),
|
||||
(None, Some(top)) => Some((top, top - upem)),
|
||||
(None, None) => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The shaper's vertical metrics callbacks: ours, so that its vertical advances and origins are
|
||||
/// the ones glyphs are placed with.
|
||||
pub(crate) struct VerticalFuncs<'a> {
|
||||
pub(crate) face: &'a FontFace,
|
||||
pub(crate) metrics: VMetrics<'a>,
|
||||
}
|
||||
|
||||
impl VerticalFuncs<'_> {
|
||||
/// The vertical origin (x, y) in whole font units: centred across, [`VMetrics::origin`] up.
|
||||
pub(crate) fn origin(&self, gid: u32) -> (i32, i32) {
|
||||
((self.face.advance(gid) / 2.0).round() as i32, self.metrics.origin(GlyphId::new(gid)).round() as i32)
|
||||
}
|
||||
}
|
||||
|
||||
impl harfrust::font::FontFuncs for VerticalFuncs<'_> {
|
||||
fn advance_height(&mut self, _: &harfrust::font::BuiltinFontFuncs, glyph: harfrust::GlyphId) -> i32 {
|
||||
// The shaper's y axis points up: advancing down the line is negative.
|
||||
-(self.metrics.advance(GlyphId::new(glyph.to_u32())).round() as i32)
|
||||
}
|
||||
|
||||
fn vertical_origin(&mut self, _: &harfrust::font::BuiltinFontFuncs, glyph: harfrust::GlyphId) -> (i32, i32) {
|
||||
self.origin(glyph.to_u32())
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -95,7 +96,9 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
|
|||
return Err(FormatError::TooNew(v as u32));
|
||||
}
|
||||
let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?;
|
||||
let mut doc: Document = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
upgrade(&mut v);
|
||||
let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
let names: Vec<String> = z.file_names().map(str::to_string).collect();
|
||||
for name in names {
|
||||
let Some(stem) = name.strip_prefix("assets/") else { continue };
|
||||
|
|
@ -109,7 +112,8 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
|
|||
}
|
||||
|
||||
fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
let mut v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
upgrade(&mut v);
|
||||
let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
if let Some(data) = v.get("assetData").and_then(Value::as_object) {
|
||||
for (k, s) in data {
|
||||
|
|
@ -124,6 +128,54 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
|
|||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Bring older documents' JSON up to the current model. Vertical Type was a text frame option
|
||||
/// (`content.options.vertical`); it is the story's direction, which a story takes from its first
|
||||
/// frame.
|
||||
fn upgrade(doc: &mut Value) {
|
||||
let mut vertical = HashSet::new();
|
||||
vertical_frames(doc, &mut vertical, 0);
|
||||
if vertical.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(stories) = doc.get_mut("stories").and_then(Value::as_object_mut) else { return };
|
||||
for st in stories.values_mut() {
|
||||
let first = st.get("frames").and_then(|f| f.get(0)).and_then(Value::as_u64);
|
||||
if let (Some(f), Some(st)) = (first, st.as_object_mut())
|
||||
&& vertical.contains(&f)
|
||||
&& !st.contains_key("vertical")
|
||||
{
|
||||
st.insert("vertical".into(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ids of the text frames in `v` (any depth: groups, anchored objects) whose options say vertical.
|
||||
fn vertical_frames(v: &Value, out: &mut HashSet<u64>, depth: usize) {
|
||||
// serde_json already refuses JSON nested deeper than 128 levels.
|
||||
if depth > 256 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Object(m) => {
|
||||
if let (Some(id), Some(c)) = (m.get("id").and_then(Value::as_u64), m.get("content"))
|
||||
&& c.get("type").and_then(Value::as_str) == Some("text")
|
||||
&& c.get("options").and_then(|o| o.get("vertical")).and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
out.insert(id);
|
||||
}
|
||||
for c in m.values() {
|
||||
vertical_frames(c, out, depth + 1);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => {
|
||||
for c in a {
|
||||
vertical_frames(c, out, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn base64_decode(s: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(s.len() * 3 / 4);
|
||||
let (mut buf, mut bits) = (0u32, 0);
|
||||
|
|
@ -179,6 +231,62 @@ mod tests {
|
|||
assert_eq!(back.stories, d.stories);
|
||||
}
|
||||
|
||||
/// The object of the item `id` in a document's JSON.
|
||||
fn item_json(v: &mut Value, id: u64) -> Option<&mut Value> {
|
||||
if v.get("id").and_then(Value::as_u64) == Some(id) && v.get("content").is_some() {
|
||||
return Some(v);
|
||||
}
|
||||
match v {
|
||||
Value::Object(m) => m.values_mut().find_map(|c| item_json(c, id)),
|
||||
Value::Array(a) => a.iter_mut().find_map(|c| item_json(c, id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_level_vertical_text_opens_as_a_vertical_story() {
|
||||
// Documents that set Vertical on the frame: the story takes its first frame's direction.
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, "縦書き", ParaFormat::default()).unwrap();
|
||||
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 72.0, 428.0, 400.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.thread(a, b).unwrap();
|
||||
let (c, other) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(450.0, 72.0, 550.0, 400.0), lid, "横", ParaFormat::default()).unwrap();
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
item_json(&mut v, a.0).unwrap()["content"]["options"]["vertical"] = json!(true);
|
||||
item_json(&mut v, c.0).unwrap()["content"]["options"]["vertical"] = json!(false);
|
||||
let json = serde_json::to_vec(&v).unwrap();
|
||||
let mut zipped = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut zipped);
|
||||
z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(&json).unwrap();
|
||||
z.finish().unwrap();
|
||||
}
|
||||
for bytes in [json.clone(), zipped.into_inner()] {
|
||||
let back = load(&bytes).unwrap();
|
||||
assert!(back.stories[&sid].vertical);
|
||||
assert!(!back.stories[&other].vertical);
|
||||
// Saved again, the direction is the story's.
|
||||
let again = load(&save(&back).unwrap()).unwrap();
|
||||
assert!(again.stories[&sid].vertical && !again.stories[&other].vertical);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_saved_before_glyph_fallback_keep_their_fallback_fonts() {
|
||||
let d = Document::new(&NewDocument::default());
|
||||
assert!(!d.settings.glyph_fallback);
|
||||
assert!(!load(&save(&d).unwrap()).unwrap().settings.glyph_fallback, "saved and read as off");
|
||||
let mut on = d.clone();
|
||||
on.settings.glyph_fallback = true;
|
||||
assert!(load(&save(&on).unwrap()).unwrap().settings.glyph_fallback);
|
||||
// Written before the setting existed: drawn from fallback fonts, as they were.
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
v["settings"].as_object_mut().unwrap().remove("glyphFallback").unwrap();
|
||||
assert!(load(&serde_json::to_vec(&v).unwrap()).unwrap().settings.glyph_fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_and_newer_versions() {
|
||||
assert!(load(b"nope").is_err());
|
||||
|
|
|
|||
|
|
@ -1837,14 +1837,13 @@ impl<'a> Ex<'a> {
|
|||
// ---------- stories ----------
|
||||
|
||||
fn story_el(&mut self, s: &Story) -> El {
|
||||
let vertical = s.frames.iter().any(|f| self.d.item(*f).and_then(|i| i.text_frame()).is_some_and(|t| t.options.vertical));
|
||||
let mut el = El::new("Story").attr("Self", uid(s.id.0)).attr("AppliedTOCStyle", "n").attr("TrackChanges", "false").attr("StoryTitle", "$ID/");
|
||||
el.push(
|
||||
El::new("StoryPreference")
|
||||
.attr("OpticalMarginAlignment", "false")
|
||||
.attr("OpticalMarginSize", "12")
|
||||
.attr("FrameType", "TextFrameType")
|
||||
.attr("StoryOrientation", if vertical { "Vertical" } else { "Horizontal" })
|
||||
.attr("StoryOrientation", if s.vertical { "Vertical" } else { "Horizontal" })
|
||||
.attr(
|
||||
"StoryDirection",
|
||||
if s.direction == designcraft_doc::TextDirection::RightToLeft { "RightToLeftDirection" } else { "LeftToRightDirection" },
|
||||
|
|
|
|||
|
|
@ -115,8 +115,6 @@ struct Importer<'r> {
|
|||
layer_ids: HashMap<String, LayerId>,
|
||||
stories: BTreeMap<StoryId, Story>,
|
||||
story_ids: HashMap<String, StoryId>,
|
||||
/// Stories with a vertical StoryOrientation (their frames set text vertically).
|
||||
vertical_stories: std::collections::HashSet<StoryId>,
|
||||
parents: Vec<Spread>,
|
||||
parent_ids: HashMap<String, SpreadId>,
|
||||
spreads: Vec<Spread>,
|
||||
|
|
@ -199,7 +197,6 @@ impl<'r> Importer<'r> {
|
|||
layers: Vec::new(),
|
||||
layer_ids: HashMap::new(),
|
||||
stories: BTreeMap::new(),
|
||||
vertical_stories: Default::default(),
|
||||
story_ids: HashMap::new(),
|
||||
parents: Vec::new(),
|
||||
parent_ids: HashMap::new(),
|
||||
|
|
@ -385,9 +382,6 @@ impl<'r> Importer<'r> {
|
|||
self.story_ids.insert(s.to_string(), id);
|
||||
}
|
||||
let story = self.story(id, e);
|
||||
if e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical") {
|
||||
self.vertical_stories.insert(id);
|
||||
}
|
||||
self.stories.insert(id, story);
|
||||
}
|
||||
// Topic cross-references (See / See also) become markers at the start of the first story
|
||||
|
|
@ -1423,6 +1417,7 @@ impl<'r> Importer<'r> {
|
|||
} else {
|
||||
designcraft_doc::TextDirection::LeftToRight
|
||||
},
|
||||
vertical: e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical"),
|
||||
rev: 0,
|
||||
tables,
|
||||
notes,
|
||||
|
|
@ -2048,7 +2043,6 @@ impl<'r> Importer<'r> {
|
|||
}
|
||||
};
|
||||
let mut options = e.find("TextFramePreference").map(text_frame_options).unwrap_or_default();
|
||||
options.vertical = self.vertical_stories.contains(&story);
|
||||
if let Some(g) = e.find("BaselineFrameGridOption")
|
||||
&& g.get("UseCustomBaselineFrameGrid") == Some("true")
|
||||
{
|
||||
|
|
@ -2248,6 +2242,7 @@ impl<'r> Importer<'r> {
|
|||
created: designcraft_doc::vars::now(),
|
||||
modified: 0,
|
||||
next_id: self.next_id,
|
||||
font_scope: 0,
|
||||
};
|
||||
Ok(d)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -701,11 +701,19 @@ fn round_trips_frames_with_pasted_in_items() {
|
|||
fn vertical_story_orientation_round_trips() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, "縦書き", ParaFormat::default()).unwrap();
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical = true;
|
||||
let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, "縦書き", ParaFormat::default()).unwrap();
|
||||
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 72.0, 428.0, 400.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.thread(a, b).unwrap();
|
||||
d.add_text_frame(SpreadRef::Doc(0), Rect::new(450.0, 72.0, 550.0, 400.0), lid, "横", ParaFormat::default()).unwrap();
|
||||
d.story_mut(sid).unwrap().vertical = true;
|
||||
// Orientation and column direction are separate settings of the story.
|
||||
d.story_mut(sid).unwrap().direction = designcraft_doc::TextDirection::RightToLeft;
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
let frame = back.spreads[0].items.iter().find_map(|i| i.text_frame()).unwrap();
|
||||
assert!(frame.options.vertical);
|
||||
use designcraft_doc::TextDirection::{LeftToRight, RightToLeft};
|
||||
let directions: Vec<_> = back.stories.values().map(|s| (s.frames.len(), s.vertical, s.direction)).collect();
|
||||
assert!(directions.contains(&(2, true, RightToLeft)) && directions.contains(&(1, false, LeftToRight)), "{directions:?}");
|
||||
let frames: Vec<bool> = back.spreads[0].items.iter().map(|i| back.frame_vertical(i)).collect();
|
||||
assert_eq!(frames, [true, true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -27,3 +27,4 @@ hayro-syntax = "0.7.2"
|
|||
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
|
||||
hayro-cmap = "0.1"
|
||||
kurbo = { workspace = true }
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
|
|
|||
|
|
@ -893,7 +893,7 @@ impl Exporter<'_> {
|
|||
}
|
||||
} else if let Some(ft) = cs.frame(it.id) {
|
||||
let text = doc.story(tfr.story).map(|st| st.text.as_str()).unwrap_or("");
|
||||
let local = it.text_local();
|
||||
let local = doc.text_local(it);
|
||||
if local != Affine::IDENTITY {
|
||||
s.push_transform(&tf(local));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ fn source_rects(doc: &Document, cache: &designcraft_compose::Cache, src: &Hyperl
|
|||
if loc.spread != SpreadRef::Doc(si) {
|
||||
continue;
|
||||
}
|
||||
let m: Affine = doc.parent_xf(&loc) * it.text_xf();
|
||||
let m: Affine = doc.parent_xf(&loc) * doc.text_xf(it);
|
||||
for l in &ft.lines {
|
||||
if l.range.end <= *start || l.range.start >= *end {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -201,3 +201,70 @@ fn tables_export_cell_text() {
|
|||
assert!(text.contains(w), "{w} missing from {text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What a page draws: each glyph (its text and outline bounds) and each filled path's bounds.
|
||||
fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec<kurbo::Rect>) {
|
||||
use hayro_interpret::font::Glyph;
|
||||
use hayro_interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
|
||||
interpret_page,
|
||||
};
|
||||
use kurbo::Shape as _;
|
||||
#[derive(Default)]
|
||||
struct Rec(Vec<(String, kurbo::Rect)>, Vec<kurbo::Rect>);
|
||||
impl Device<'_> for Rec {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, p: &kurbo::BezPath, xf: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {
|
||||
self.1.push((xf * p.clone()).bounding_box());
|
||||
}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, g: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {
|
||||
let text = match g.as_unicode() {
|
||||
Some(hayro_cmap::BfString::Char(c)) => c.to_string(),
|
||||
Some(hayro_cmap::BfString::String(s)) => s,
|
||||
None => String::new(),
|
||||
};
|
||||
let bounds = match g {
|
||||
Glyph::Outline(o) => o.outline().bounding_box(),
|
||||
Glyph::Type3(_) => kurbo::Rect::ZERO,
|
||||
};
|
||||
self.0.push((text, bounds));
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
|
||||
let cache = InterpreterCache::new();
|
||||
let page = &pdf.pages()[0];
|
||||
let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
|
||||
let mut rec = Rec::default();
|
||||
interpret_page(page, &mut ctx, &mut rec);
|
||||
(rec.0, rec.1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_glyphs_print_as_boxes() {
|
||||
// Source Serif 4 lacks 語: its .notdef box is drawn as a path, not as a .notdef glyph
|
||||
// (which PDF/A and PDF/UA forbid).
|
||||
let boxes = |paths: &[kurbo::Rect]| paths.iter().filter(|r| r.width() > 1.0 && r.width() < 12.0 && r.height() > 3.0 && r.height() < 12.0).count();
|
||||
let d = doc_with_text("a語b");
|
||||
assert!(!d.settings.glyph_fallback);
|
||||
let (glyphs, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap());
|
||||
assert_eq!(glyphs.iter().map(|g| g.0.as_str()).collect::<Vec<_>>(), ["a", "b"], "{glyphs:?}");
|
||||
assert_eq!(boxes(&paths), 1, "{paths:?}");
|
||||
let a = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, ..Default::default() });
|
||||
assert!(a.is_ok(), "PDF/A takes the box: {:?}", a.err());
|
||||
// A font whose .notdef is empty: the box DesignCraft draws for it.
|
||||
const FAMILY: &str = "DC Test PDF Empty Notdef";
|
||||
let font = designcraft_fonts::testing::font_mapping(FAMILY, &[('a', 'a'), ('b', 'b')]).unwrap();
|
||||
designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::with_empty_notdef(font).unwrap());
|
||||
let mut d = doc_with_text("a語b");
|
||||
let sid = *d.stories.keys().next().unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0.."a語b".len(), |f| f.over.font_family = Some(FAMILY.into()));
|
||||
let (glyphs, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap());
|
||||
assert_eq!(glyphs.iter().map(|g| g.0.as_str()).collect::<Vec<_>>(), ["a", "b"], "{glyphs:?}");
|
||||
assert_eq!(boxes(&paths), 1, "{paths:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,11 @@ fn is_marker(c: char) -> bool {
|
|||
('\u{E000}'..='\u{E1FF}').contains(&c)
|
||||
}
|
||||
|
||||
/// Can `b` join the run `a` is in? A missing glyph (.notdef) is a run of its own.
|
||||
fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool {
|
||||
b.visible
|
||||
&& a.gid != 0
|
||||
&& b.gid != 0
|
||||
&& a.face.id() == b.face.id()
|
||||
&& a.style == b.style
|
||||
&& (a.sx - b.sx).abs() < 1e-9
|
||||
|
|
@ -277,6 +280,11 @@ impl Exporter<'_> {
|
|||
let hs = g0.sx / g0.sy;
|
||||
let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0);
|
||||
// A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text.
|
||||
if g0.gid == 0 {
|
||||
self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight);
|
||||
return;
|
||||
}
|
||||
let Some(font) = self.font(&face) else {
|
||||
self.warn(format!("font `{} {}` could not be embedded; its text was drawn as outlines", face.family, face.style));
|
||||
self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
//! Glyph grids for the Glyphs panel: a font's characters drawn into square cells.
|
||||
|
||||
use designcraft_fonts::FontDb;
|
||||
use designcraft_fonts::ScopedFonts;
|
||||
use vello_cpu::{RenderContext, Resources, kurbo, peniko};
|
||||
|
||||
use crate::Rendered;
|
||||
|
||||
/// Draw `chars` of `family`/`style` into a grid of `cols` columns of `cell`-pixel cells, in
|
||||
/// `color` (straight RGBA) on transparency. Glyphs are scaled to the font's em and centred.
|
||||
pub fn glyph_grid(family: &str, style: &str, chars: &[char], cols: u32, cell: u32, color: [u8; 4]) -> Rendered {
|
||||
let db = FontDb::global();
|
||||
/// Draw `chars` of `family`/`style` (as `db`, a document's fonts, has it) into a grid of `cols`
|
||||
/// columns of `cell`-pixel cells, in `color` (straight RGBA) on transparency. Glyphs are scaled to
|
||||
/// the font's em and centred.
|
||||
pub fn glyph_grid(db: &ScopedFonts<'_>, family: &str, style: &str, chars: &[char], cols: u32, cell: u32, color: [u8; 4]) -> Rendered {
|
||||
let face = db.face(family, style);
|
||||
let cols = cols.max(1);
|
||||
let rows = (chars.len() as u32).div_ceil(cols).max(1);
|
||||
|
|
@ -45,10 +45,10 @@ pub fn glyph_grid(family: &str, style: &str, chars: &[char], cols: u32, cell: u3
|
|||
Rendered { width: w as u32, height: h as u32, pixels }
|
||||
}
|
||||
|
||||
/// `text` set in `family`/`style` on one line, `height` pixels tall (the em fits the height), in
|
||||
/// `color` on transparency — for font menus that preview each family.
|
||||
pub fn text_line(family: &str, style: &str, text: &str, height: u32, color: [u8; 4]) -> Rendered {
|
||||
let db = FontDb::global();
|
||||
/// `text` set in `family`/`style` (as `db`, a document's fonts, has it) on one line, `height`
|
||||
/// pixels tall (the em fits the height), in `color` on transparency — for font menus that preview
|
||||
/// each family.
|
||||
pub fn text_line(db: &ScopedFonts<'_>, family: &str, style: &str, text: &str, height: u32, color: [u8; 4]) -> Rendered {
|
||||
let face = db.face(family, style);
|
||||
let upem = face.units_per_em().max(1.0);
|
||||
let k = height as f64 * 0.72 / upem;
|
||||
|
|
@ -60,12 +60,12 @@ pub fn text_line(family: &str, style: &str, text: &str, height: u32, color: [u8;
|
|||
let (asc, desc) = face.vertical_metrics();
|
||||
let baseline = h as f64 / 2.0 + (asc - desc.abs()) / 2.0 * k;
|
||||
let mut x = 1.0;
|
||||
// A preview shows what the font draws: not the box DesignCraft draws for a missing glyph.
|
||||
let boxed = glyphs.iter().any(|g| g.gid == 0) && db.missing_box(&face).is_some();
|
||||
for g in &glyphs {
|
||||
let outline = db.outline(&face, g.gid);
|
||||
if !outline.elements().is_empty() {
|
||||
ctx.set_transform(
|
||||
kurbo::Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * kurbo::Affine::scale_non_uniform(k, -k),
|
||||
);
|
||||
if !(outline.elements().is_empty() || (boxed && g.gid == 0)) {
|
||||
ctx.set_transform(kurbo::Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * kurbo::Affine::scale(k));
|
||||
ctx.fill_path(&outline);
|
||||
}
|
||||
x += g.x_advance as f64 * k;
|
||||
|
|
@ -85,7 +85,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn grid_draws_each_cell() {
|
||||
let img = glyph_grid(designcraft_fonts::DEFAULT_FAMILY, "Regular", &['A', 'B', 'C', ' '], 2, 40, [0, 0, 0, 255]);
|
||||
let db = designcraft_fonts::FontDb::global().scoped(0);
|
||||
let img = glyph_grid(&db, designcraft_fonts::DEFAULT_FAMILY, "Regular", &['A', 'B', 'C', ' '], 2, 40, [0, 0, 0, 255]);
|
||||
assert_eq!((img.width, img.height), (80, 80));
|
||||
let ink = |cx: u32, cy: u32| {
|
||||
(cx * 40..cx * 40 + 40).flat_map(|x| (cy * 40..cy * 40 + 40).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 128).count()
|
||||
|
|
@ -96,10 +97,27 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn text_line_draws_the_name() {
|
||||
let img = text_line(designcraft_fonts::DEFAULT_FAMILY, "Regular", "Serif", 24, [0, 0, 0, 255]);
|
||||
let db = designcraft_fonts::FontDb::global().scoped(0);
|
||||
let img = text_line(&db, designcraft_fonts::DEFAULT_FAMILY, "Regular", "Serif", 24, [0, 0, 0, 255]);
|
||||
assert_eq!(img.height, 24);
|
||||
assert!(img.width > 30);
|
||||
let ink = (0..img.width).flat_map(|x| (0..img.height).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 128).count();
|
||||
assert!(ink > 40, "{ink}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_line_draws_upright_and_whole() {
|
||||
let db = designcraft_fonts::FontDb::global().scoped(0);
|
||||
let img = text_line(&db, designcraft_fonts::DEFAULT_FAMILY, "Regular", "L", 40, [0, 0, 0, 255]);
|
||||
let inked = |x: u32, y: u32| img.pixel(x, y)[3] > 128;
|
||||
let row_ink = |y: u32| (0..img.width).filter(|&x| inked(x, y)).count();
|
||||
let rows: Vec<u32> = (0..img.height).filter(|&y| row_ink(y) > 0).collect();
|
||||
let (Some(&top), Some(&bottom)) = (rows.first(), rows.last()) else { panic!("no ink") };
|
||||
// Nothing touches the edges: the letter isn't cut off.
|
||||
assert!(top > 0 && bottom < img.height - 1, "clipped: ink rows {top}..={bottom} of {}", img.height);
|
||||
assert!((0..img.height).all(|y| !inked(0, y) && !inked(img.width - 1, y)), "clipped at the sides");
|
||||
// An upright L has its bar at the bottom: its widest ink row is in the lower half.
|
||||
let widest = rows.iter().copied().max_by_key(|&y| row_ink(y)).unwrap_or(top);
|
||||
assert!(widest > (top + bottom) / 2, "upside down: widest row {widest} in ink rows {top}..={bottom}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -579,7 +579,7 @@ impl Renderer {
|
|||
if let Some(ft) = cs.frame(it.id) {
|
||||
match &tf.options.path {
|
||||
Some(pt) => self.draw_path_text(ctx, f, &cs, ft, xf, bp, pt),
|
||||
None => self.draw_text(ctx, f, &cs, ft, xf * it.text_local()),
|
||||
None => self.draw_text(ctx, f, &cs, ft, xf * f.doc.text_local(it)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ log = { workspace = true }
|
|||
[dev-dependencies]
|
||||
# Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`.
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -946,7 +946,7 @@ fn draw_text_selection(
|
|||
if let Some(sel) = cells.filter(|c| c.story == ts.story) {
|
||||
for ft in &cs.frames {
|
||||
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue };
|
||||
let m = a * it.text_xf();
|
||||
let m = a * doc.text_xf(it);
|
||||
for t in ft.tables.iter().filter(|t| t.table == sel.table) {
|
||||
for c in t.cells.iter().filter(|c| sel.range.contains(c.row, c.col)) {
|
||||
let q = [
|
||||
|
|
@ -969,7 +969,7 @@ fn draw_text_selection(
|
|||
for (fi, ft) in cs.frames.iter().enumerate() {
|
||||
let Some((a, _)) = item_canvas_xf(doc, layout, ft.frame) else { continue };
|
||||
let Some(it) = doc.item(ft.frame) else { continue };
|
||||
let m = a * it.text_xf();
|
||||
let m = a * doc.text_xf(it);
|
||||
// Show the frame edge while typing.
|
||||
for poly in path_screen(it, xf, a) {
|
||||
painter.add(egui::Shape::line(poly, Stroke::new(1.0, layer_color(doc, it).gamma_multiply(0.6))));
|
||||
|
|
@ -1029,7 +1029,7 @@ fn draw_text_selection(
|
|||
&& let Some(ft) = cs.frames.get(fi)
|
||||
&& let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame))
|
||||
{
|
||||
let m = a * it.text_xf();
|
||||
let m = a * doc.text_xf(it);
|
||||
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
|
||||
if blink {
|
||||
let p0 = xf.to_screen(m * Point::new(x, bl - asc));
|
||||
|
|
@ -1060,7 +1060,7 @@ fn draw_cell_selection(
|
|||
let Some((fi, origin, text)) = found else { return };
|
||||
let Some(ft) = cs.frames.get(fi) else { return };
|
||||
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return };
|
||||
let m = a * it.text_xf() * designcraft_geom::Affine::translate(origin.to_vec2());
|
||||
let m = a * doc.text_xf(it) * designcraft_geom::Affine::translate(origin.to_vec2());
|
||||
let range = ts.range();
|
||||
if !range.is_empty()
|
||||
&& let Some(cft) = text.frames.first()
|
||||
|
|
@ -1088,7 +1088,7 @@ fn draw_cell_selection(
|
|||
None => compose::cell_caret(cs, cell.table, cell.row, cell.col, ts.focus),
|
||||
}
|
||||
{
|
||||
let m = a * it.text_xf();
|
||||
let m = a * doc.text_xf(it);
|
||||
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
|
||||
if blink {
|
||||
painter.line_segment(
|
||||
|
|
@ -1493,7 +1493,7 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc
|
|||
let cs = app.session.cache.get(doc, story.id, None);
|
||||
for ft in &cs.frames {
|
||||
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue };
|
||||
let m = a * it.text_xf();
|
||||
let m = a * doc.text_xf(it);
|
||||
let col = layer_color(doc, it);
|
||||
for l in &ft.lines {
|
||||
let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32;
|
||||
|
|
@ -1561,7 +1561,7 @@ fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painter: &egui::P
|
|||
let red = Stroke::new(1.0, Color32::from_rgb(230, 30, 30));
|
||||
for ft in &cs.frames {
|
||||
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue };
|
||||
let m = a * it.text_xf();
|
||||
let m = a * doc.text_xf(it);
|
||||
for l in &ft.lines {
|
||||
for r in bad.iter().filter(|r| r.start < l.range.end && r.end > l.range.start) {
|
||||
let gs: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= r.start && g.byte < r.end).collect();
|
||||
|
|
|
|||
|
|
@ -518,6 +518,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
|||
"type" => {
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Type Options")).font(semibold(12.0)));
|
||||
check(ui, d, "typographersQuotes", crate::i18n::tr(&app.ui.language, "Use Typographer's Quotes"));
|
||||
check(ui, d, "showFontNamesInEnglish", crate::i18n::tr(&app.ui.language, "Show Font Names in English"));
|
||||
ui.add_space(6.0);
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
|
|
@ -536,6 +537,11 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
|||
check(ui, d, "highlightHj", crate::i18n::tr(&app.ui.language, "H&J Violations"));
|
||||
check(ui, d, "highlightCustomTracking", crate::i18n::tr(&app.ui.language, "Custom Tracking/Kerning"));
|
||||
check(ui, d, "highlightSubstitutedFonts", crate::i18n::tr(&app.ui.language, "Substituted Fonts"));
|
||||
if d.fields.contains_key("glyphFallback") {
|
||||
ui.add_space(6.0);
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Missing Glyphs")).font(semibold(12.0)));
|
||||
check(ui, d, "glyphFallback", crate::i18n::tr(&app.ui.language, "Draw Missing Glyphs from Fallback Fonts"));
|
||||
}
|
||||
}
|
||||
"advancedType" => {
|
||||
ui.label(crate::rtl::widget(
|
||||
|
|
@ -1508,7 +1514,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
|||
"preferences" => {
|
||||
app.run(
|
||||
"prefs.set",
|
||||
json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "transformationsAreTotals": d.b("transformationsAreTotals"), "absolutePageNumbers": d.b("absolutePageNumbers"), "highlightHj": d.b("highlightHj"), "highlightKeeps": d.b("highlightKeeps"), "highlightCustomTracking": d.b("highlightCustomTracking"), "highlightSubstitutedFonts": d.b("highlightSubstitutedFonts"), "richBlackOutput": d.b("richBlackOutput"), "typographersQuotes": d.b("typographersQuotes"), "smartTextReflow": d.b("smartTextReflow"),
|
||||
json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "transformationsAreTotals": d.b("transformationsAreTotals"), "absolutePageNumbers": d.b("absolutePageNumbers"), "highlightHj": d.b("highlightHj"), "highlightKeeps": d.b("highlightKeeps"), "highlightCustomTracking": d.b("highlightCustomTracking"), "highlightSubstitutedFonts": d.b("highlightSubstitutedFonts"), "richBlackOutput": d.b("richBlackOutput"), "typographersQuotes": d.b("typographersQuotes"), "showFontNamesInEnglish": d.b("showFontNamesInEnglish"), "smartTextReflow": d.b("smartTextReflow"),
|
||||
"autocorrect": d.b("autocorrect"), "showAddedText": d.b("showAddedText"), "showNoteAnchors": d.b("showNoteAnchors"),
|
||||
"recoveryMinutes": d.n("recoveryMinutes").unwrap_or(0.5),
|
||||
"autocorrectList": d.s("autocorrectText").lines().filter_map(|l| {
|
||||
|
|
@ -1591,6 +1597,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
|||
}
|
||||
doc["advancedType"] = adv;
|
||||
doc["overprintBlack"] = json!(d.b("overprintBlack"));
|
||||
doc["glyphFallback"] = json!(d.b("glyphFallback"));
|
||||
app.run("document.preferences", doc)
|
||||
}
|
||||
"newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})),
|
||||
|
|
@ -1963,21 +1970,21 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
|
|||
}
|
||||
}
|
||||
"chars" => {
|
||||
let fonts = crate::panels::fonts(app);
|
||||
let menu = crate::panels::font_menu(app);
|
||||
egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:"));
|
||||
let fam = cur(d, "c.fontFamily", &cv["fontFamily"]).as_str().unwrap_or("").to_string();
|
||||
egui::ComboBox::from_id_salt("psfam").selected_text(&fam).width(200.0).show_ui(ui, |ui| {
|
||||
for f in designcraft_fonts::FontDb::global().families() {
|
||||
if ui.selectable_label(f == fam, &f).clicked() {
|
||||
d.fields.insert("c.fontFamily".into(), json!(f));
|
||||
}
|
||||
egui::ComboBox::from_id_salt("psfam").selected_text(crate::panels::font_label(app, &menu, &fam)).width(200.0).show_ui(ui, |ui| {
|
||||
if let Some(f) = crate::panels::font_menu_rows(app, ui, &menu, &fam) {
|
||||
d.fields.insert("c.fontFamily".into(), json!(f));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Style:"));
|
||||
let sty = cur(d, "c.fontStyle", &cv["fontStyle"]).as_str().unwrap_or("").to_string();
|
||||
egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| {
|
||||
for s in designcraft_fonts::FontDb::global().styles(&fam) {
|
||||
for s in fonts.styles(&fam) {
|
||||
if ui.selectable_label(s == sty, &s).clicked() {
|
||||
d.fields.insert("c.fontStyle".into(), json!(s));
|
||||
}
|
||||
|
|
@ -2088,6 +2095,15 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
|
|||
d.fields.insert("p.kashidas".into(), json!(k));
|
||||
}
|
||||
ui.end_row();
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Korean Line Breaks:"));
|
||||
let mut kb = cur(d, "p.koreanCharBreaks", &pv["koreanCharBreaks"]).as_bool().unwrap_or(false);
|
||||
if ui
|
||||
.checkbox(&mut kb, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Between syllables (not only at spaces)")))
|
||||
.changed()
|
||||
{
|
||||
d.fields.insert("p.koreanCharBreaks".into(), json!(kb));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
"color" => {
|
||||
|
|
@ -2612,9 +2628,10 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
|||
});
|
||||
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 = designcraft_fonts::FontDb::global();
|
||||
let families = db.families();
|
||||
let fam_opts: Vec<(&str, &str)> = families.iter().map(|f| (f.as_str(), f.as_str())).collect();
|
||||
let db = crate::panels::fonts(app);
|
||||
let menu = crate::panels::font_menu(app);
|
||||
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| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:"));
|
||||
combo(ui, d, "toFamily", &fam_opts);
|
||||
|
|
|
|||
|
|
@ -1916,6 +1916,17 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
|||
"在两端对齐的阿拉伯语文本中",
|
||||
],
|
||||
),
|
||||
("Korean Line Breaks:", ["Korean Line Breaks:", "Korean Line Breaks:", "Korean Line Breaks:", "Korean Line Breaks:", "韩文换行:"]),
|
||||
(
|
||||
"Between syllables (not only at spaces)",
|
||||
[
|
||||
"Between syllables (not only at spaces)",
|
||||
"Between syllables (not only at spaces)",
|
||||
"Between syllables (not only at spaces)",
|
||||
"Between syllables (not only at spaces)",
|
||||
"在音节之间(不仅在空格处)",
|
||||
],
|
||||
),
|
||||
("Binding:", ["Binding:", "Binding:", "Binding:", "Binding:", "装订方向:"]),
|
||||
("Ruby:", ["Ruby:", "Ruby:", "Ruby:", "Ruby:", "注音:"]),
|
||||
(
|
||||
|
|
@ -2548,6 +2559,27 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
|||
("Interface", ["Interface", "Interface", "Interface", "Interface", "界面"]),
|
||||
("Advanced Type", ["Advanced Type", "Advanced Type", "Advanced Type", "Advanced Type", "高级文字"]),
|
||||
("Composition", ["Composition", "Composition", "Composition", "Composition", "排版"]),
|
||||
(
|
||||
"Show Font Names in English",
|
||||
[
|
||||
"Schriftnamen auf Englisch anzeigen",
|
||||
"Afficher les noms de police en anglais",
|
||||
"Mostrar nombres de fuentes en inglés",
|
||||
"フォント名を英語で表示",
|
||||
"以英文显示字体名称",
|
||||
],
|
||||
),
|
||||
("Missing Glyphs", ["Fehlende Glyphen", "Glyphes manquants", "Glifos que faltan", "欠落グリフ", "缺失字形"]),
|
||||
(
|
||||
"Draw Missing Glyphs from Fallback Fonts",
|
||||
[
|
||||
"Fehlende Glyphen aus Ersatzschriften zeichnen",
|
||||
"Dessiner les glyphes manquants avec des polices de substitution",
|
||||
"Dibujar los glifos que faltan con fuentes alternativas",
|
||||
"欠落グリフを代替フォントで表示",
|
||||
"用后备字体绘制缺失字形",
|
||||
],
|
||||
),
|
||||
("Units & Increments", ["Units & Increments", "Units & Increments", "Units & Increments", "Units & Increments", "单位与增量"]),
|
||||
("Grids", ["Grids", "Grids", "Grids", "Grids", "网格"]),
|
||||
("Guides & Pasteboard", ["Guides & Pasteboard", "Guides & Pasteboard", "Guides & Pasteboard", "Guides & Pasteboard", "参考线与粘贴板"]),
|
||||
|
|
|
|||
|
|
@ -335,6 +335,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
|||
("Glyph Scaling:", "تحجيم الحروف:"),
|
||||
("Insert Kashidas:", "إدراج الكشيدة:"),
|
||||
("In justified Arabic text", "في النص العربي المضبوط"),
|
||||
("Korean Line Breaks:", "فواصل الأسطر الكورية:"),
|
||||
("Between syllables (not only at spaces)", "بين المقاطع (وليس عند المسافات فقط)"),
|
||||
("Minimum Space Before First Footnote:", "أقل مسافة قبل الحاشية الأولى:"),
|
||||
("Space Between Footnotes:", "المسافة بين الحواشي:"),
|
||||
("Offset:", "الإزاحة:"),
|
||||
|
|
@ -969,6 +971,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
|||
("Interface", "الواجهة"),
|
||||
("Advanced Type", "إعدادات النص المتقدمة"),
|
||||
("Composition", "تنضيد النص"),
|
||||
("Show Font Names in English", "إظهار أسماء الخطوط بالإنجليزية"),
|
||||
("Missing Glyphs", "الحروف الناقصة"),
|
||||
("Draw Missing Glyphs from Fallback Fonts", "رسم الحروف الناقصة بخطوط بديلة"),
|
||||
("Units & Increments", "الوحدات والزيادات"),
|
||||
("Grids", "الشبكات"),
|
||||
("Guides & Pasteboard", "الأدلة ولوحة اللصق"),
|
||||
|
|
|
|||
|
|
@ -412,8 +412,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
|||
"cmd:endnote.insert",
|
||||
"cmd:math.insert",
|
||||
">Story Direction",
|
||||
"cmd:object.textFrameOptions|Horizontal|{\"vertical\": false}",
|
||||
"cmd:object.textFrameOptions|Vertical|{\"vertical\": true}",
|
||||
"cmd:type.storyDirection|Horizontal|{\"vertical\": false}",
|
||||
"cmd:type.storyDirection|Vertical|{\"vertical\": true}",
|
||||
"<",
|
||||
"cmd:type.tateChuYoko",
|
||||
"ui:app.rubyDialog",
|
||||
|
|
@ -955,6 +955,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
|||
let doc = app.session.execute("document.preferences", &json!({})).unwrap_or_default();
|
||||
f["horizontalUnits"] = doc["horizontalUnits"].clone();
|
||||
f["overprintBlack"] = doc["overprintBlack"].clone();
|
||||
f["glyphFallback"] = doc["glyphFallback"].clone();
|
||||
for k in ["superscriptSize", "superscriptPosition", "subscriptSize", "subscriptPosition"] {
|
||||
f[format!("adv.{k}")] = json!(format!("{}", doc["advancedType"][k].as_f64().unwrap_or(0.0)));
|
||||
}
|
||||
|
|
@ -1692,6 +1693,15 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option<bool> {
|
|||
"window.toolsDoubleColumn" => app.ui.tools_double_column,
|
||||
"view.togglePreview" => app.ui.screen_mode == crate::ScreenMode::Preview,
|
||||
"window.brightness" => params.get("brightness").and_then(Value::as_str) == Some(app.ui.brightness.id()),
|
||||
"type.storyDirection" => {
|
||||
let st = app.session.active()?;
|
||||
let sid = st
|
||||
.selection
|
||||
.text
|
||||
.map(|t| t.story)
|
||||
.or_else(|| st.selection.items.iter().find_map(|i| st.doc.item(*i)?.text_frame().map(|t| t.story)))?;
|
||||
st.doc.story(sid)?.vertical == params.get("vertical").and_then(Value::as_bool)?
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -2152,6 +2162,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_direction_is_checked() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
app.session.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "縦", "vertical": true})).unwrap();
|
||||
assert_eq!(checked(&app, "type.storyDirection", &json!({"vertical": true})), Some(true));
|
||||
assert_eq!(checked(&app, "type.storyDirection", &json!({"vertical": false})), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_setup_and_colors() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
//! Glyphs panel: every character of a font in a grid; click to insert it at the text cursor.
|
||||
|
||||
use designcraft_fonts::FontDb;
|
||||
use egui::{Sense, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -32,14 +31,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
|||
.unwrap_or_else(|| designcraft_fonts::DEFAULT_FAMILY.into());
|
||||
st.style = a.as_ref().and_then(|a| a["chars"]["fontStyle"].as_str().map(str::to_string)).unwrap_or_else(|| "Regular".into());
|
||||
}
|
||||
let db = FontDb::global();
|
||||
let (db, scope) = (super::fonts(app), super::font_scope(app));
|
||||
ui.horizontal(|ui| {
|
||||
egui::ComboBox::from_id_salt("glyph_family").selected_text(&st.family).width(140.0).show_ui(ui, |ui| {
|
||||
for f in db.families() {
|
||||
if ui.selectable_label(f == st.family, &f).clicked() {
|
||||
st.style = db.styles(&f).into_iter().next().unwrap_or_else(|| "Regular".into());
|
||||
st.family = f;
|
||||
}
|
||||
let menu = super::font_menu(app);
|
||||
egui::ComboBox::from_id_salt("glyph_family").selected_text(super::font_label(app, &menu, &st.family)).width(140.0).show_ui(ui, |ui| {
|
||||
if let Some(f) = super::font_menu_rows(app, ui, &menu, &st.family) {
|
||||
st.style = db.styles(&f).into_iter().next().unwrap_or_else(|| "Regular".into());
|
||||
st.family = f;
|
||||
}
|
||||
});
|
||||
egui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| {
|
||||
|
|
@ -80,11 +78,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
|||
let ppp = ui.ctx().pixels_per_point();
|
||||
let cell_px = (CELL * ppp).round() as u32;
|
||||
let ink = t.text_strong;
|
||||
let key = egui::Id::new(("glyph_grid", &st.family, &st.style, &q, cols, cell_px, ink.to_array()));
|
||||
let key = egui::Id::new(("glyph_grid", &st.family, &st.style, scope, &q, cols, cell_px, ink.to_array()));
|
||||
let tex: egui::TextureHandle = match ui.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let img = designcraft_render::glyphs::glyph_grid(&st.family, &st.style, &chars, cols, cell_px, ink.to_array());
|
||||
let img = designcraft_render::glyphs::glyph_grid(&db, &st.family, &st.style, &chars, cols, cell_px, ink.to_array());
|
||||
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||
let h = ui.ctx().load_texture("glyph_grid", ci, egui::TextureOptions::LINEAR);
|
||||
ui.data_mut(|d| d.insert_temp(key, h.clone()));
|
||||
|
|
|
|||
|
|
@ -102,6 +102,55 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> {
|
|||
}
|
||||
|
||||
/// Resolved attributes at the text selection (or the selected text frames).
|
||||
/// The fonts the active document sees: its own (its `Document Fonts` folder) ahead of the shared
|
||||
/// ones.
|
||||
pub fn fonts(app: &DesignApp) -> designcraft_fonts::ScopedFonts<'static> {
|
||||
designcraft_fonts::FontDb::global().scoped(font_scope(app))
|
||||
}
|
||||
|
||||
/// The font menus' families in menu order: Western (and other scripts) first, then Japanese,
|
||||
/// Simplified Chinese, Traditional Chinese and Korean, each alphabetical by the name shown (CJK
|
||||
/// families by their native names unless Show Font Names in English).
|
||||
pub fn font_menu(app: &DesignApp) -> Vec<designcraft_fonts::FamilyInfo> {
|
||||
let mut v = fonts(app).family_infos();
|
||||
designcraft_fonts::sort_for_menu(&mut v, app.session.prefs.show_font_names_in_english);
|
||||
v
|
||||
}
|
||||
|
||||
/// The name the font menus show for `family` (its native name, see [`font_menu`]).
|
||||
pub fn font_label(app: &DesignApp, menu: &[designcraft_fonts::FamilyInfo], family: &str) -> String {
|
||||
let english = app.session.prefs.show_font_names_in_english;
|
||||
menu.iter().find(|f| f.family == family).map_or(family, |f| f.label(english)).to_string()
|
||||
}
|
||||
|
||||
/// `menu` as rows of a popup or combo box: the name shown (the English name on hover when they
|
||||
/// differ), a separator between groups. Returns the family clicked.
|
||||
pub fn font_menu_rows(app: &DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option<String> {
|
||||
let english = app.session.prefs.show_font_names_in_english;
|
||||
let mut pick = None;
|
||||
let mut group = None;
|
||||
for f in menu {
|
||||
if group.is_some_and(|g| g != f.group) {
|
||||
ui.separator();
|
||||
}
|
||||
group = Some(f.group);
|
||||
let label = f.label(english);
|
||||
let mut resp = ui.selectable_label(f.family == current, label);
|
||||
if label != f.family {
|
||||
resp = resp.on_hover_text(&f.family);
|
||||
}
|
||||
if resp.clicked() {
|
||||
pick = Some(f.family.clone());
|
||||
}
|
||||
}
|
||||
pick
|
||||
}
|
||||
|
||||
/// The active document's font scope (0 without a document or fonts of its own).
|
||||
pub fn font_scope(app: &DesignApp) -> u32 {
|
||||
app.session.active().map_or(0, |d| d.doc.font_scope)
|
||||
}
|
||||
|
||||
pub fn text_attrs(app: &mut DesignApp) -> Option<Value> {
|
||||
let v = app.session.execute("type.selectionAttrs", &json!({})).ok()?;
|
||||
if v.is_null() { None } else { Some(serde_json::to_value(v).ok()?) }
|
||||
|
|
@ -189,16 +238,23 @@ fn paint_star(p: &egui::Painter, c: egui::Pos2, r: f32, filled: bool, color: egu
|
|||
/// The Font menu: search, favourites (★, Show Favorites Only), and each family's name shown in
|
||||
/// that family.
|
||||
pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, width: f32) {
|
||||
let fams = designcraft_fonts::FontDb::global().families();
|
||||
let (fonts, scope) = (fonts(app), font_scope(app));
|
||||
let menu = font_menu(app);
|
||||
let english = app.session.prefs.show_font_names_in_english;
|
||||
let shown_current = font_label(app, &menu, current);
|
||||
let mut favs = app.session.prefs.favorite_fonts.clone();
|
||||
let mut pick = None;
|
||||
let mut favs_changed = false;
|
||||
let state_id = egui::Id::new("font_menu_state");
|
||||
let (mut query, mut only_favs): (String, bool) = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
egui::ComboBox::from_id_salt("font_family").selected_text(if current.is_empty() { "—" } else { current }).width(width).height(420.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
egui::ComboBox::from_id_salt("font_family")
|
||||
.selected_text(if current.is_empty() { "—" } else { &shown_current })
|
||||
.width(width)
|
||||
.height(420.0)
|
||||
// The search field, Favorites and the stars keep the menu open; picking a font closes it.
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show_ui(ui, |ui| {
|
||||
ui.set_min_width(300.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
|
|
@ -212,13 +268,24 @@ pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str,
|
|||
});
|
||||
let q = query.to_lowercase();
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let shown: Vec<&String> =
|
||||
fams.iter().filter(|f| (q.is_empty() || f.to_lowercase().contains(&q)) && (!only_favs || favs.contains(f))).collect();
|
||||
for f in shown {
|
||||
let (row, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click());
|
||||
let shown: Vec<&designcraft_fonts::FamilyInfo> =
|
||||
menu.iter().filter(|f| f.matches(&q) && (!only_favs || favs.contains(&f.family))).collect();
|
||||
let mut group = None;
|
||||
for info in shown {
|
||||
// A separator between the Western fonts and each CJK language's.
|
||||
if group.is_some_and(|g| g != info.group) {
|
||||
ui.separator();
|
||||
}
|
||||
group = Some(info.group);
|
||||
let f = &info.family;
|
||||
let label = info.label(english);
|
||||
let (row, mut resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click());
|
||||
if !ui.is_rect_visible(row) {
|
||||
continue;
|
||||
}
|
||||
if label != f {
|
||||
resp = resp.on_hover_text(f);
|
||||
}
|
||||
if f == current {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
|
|
@ -237,12 +304,12 @@ pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str,
|
|||
}
|
||||
favs_changed = true;
|
||||
}
|
||||
ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, f, egui::FontId::proportional(12.0), t.text);
|
||||
ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text);
|
||||
// The name in its own face (rendered once per family and scale).
|
||||
let key = egui::Id::new(("font_preview", f, (ppp * 100.0) as u32, t.text.to_array()));
|
||||
let key = egui::Id::new(("font_preview", f, scope, (ppp * 100.0) as u32, t.text.to_array()));
|
||||
let tex: Option<egui::TextureHandle> = ui.data(|d| d.get_temp(key));
|
||||
let tex = tex.unwrap_or_else(|| {
|
||||
let img = designcraft_render::glyphs::text_line(f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array());
|
||||
let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array());
|
||||
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||
let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR);
|
||||
ui.data_mut(|d| d.insert_temp(key, h.clone()));
|
||||
|
|
@ -258,23 +325,23 @@ pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str,
|
|||
);
|
||||
if resp.clicked() {
|
||||
pick = Some(f.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(state_id, (query, only_favs)));
|
||||
if favs_changed {
|
||||
let _ = app.run("prefs.set", json!({ "favoriteFonts": favs }));
|
||||
}
|
||||
if let Some(f) = pick {
|
||||
let styles = designcraft_fonts::FontDb::global().styles(&f);
|
||||
let styles = fonts.styles(&f);
|
||||
let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
|
||||
let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}}));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn font_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, current: &str, width: f32) {
|
||||
let styles = designcraft_fonts::FontDb::global().styles(family);
|
||||
let styles = fonts(app).styles(family);
|
||||
let mut pick = None;
|
||||
egui::ComboBox::from_id_salt("font_style").selected_text(if current.is_empty() { "—" } else { current }).width(width).show_ui(ui, |ui| {
|
||||
for s in &styles {
|
||||
|
|
@ -304,3 +371,29 @@ pub fn para_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str,
|
|||
let _ = app.run("style.paragraph.apply", json!({"name": n, "clearOverrides": false}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod font_menu_tests {
|
||||
use designcraft_fonts::FontGroup;
|
||||
use designcraft_fonts::testing::{font_with, with_names};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cjk_fonts_follow_the_western_ones_under_their_native_names() {
|
||||
const FAMILY: &str = "DC UI Test Mincho";
|
||||
let font = font_with(FAMILY, &['a']).unwrap();
|
||||
let font = with_names(&font, &[(3, 1, 0x409, 1, FAMILY), (3, 1, 0x409, 2, "Regular"), (3, 1, 0x0411, 1, "UIテスト明朝")]).unwrap();
|
||||
designcraft_fonts::FontDb::global().add_font(font);
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
let menu = font_menu(&app);
|
||||
let at = menu.iter().position(|f| f.family == FAMILY).unwrap();
|
||||
assert_eq!(menu[at].group, FontGroup::Japanese);
|
||||
assert!(menu[..at].iter().any(|f| f.family == designcraft_fonts::DEFAULT_FAMILY), "Western fonts first");
|
||||
assert!(menu.windows(2).all(|w| w[0].group <= w[1].group), "grouped");
|
||||
assert_eq!(font_label(&app, &menu, FAMILY), "UIテスト明朝");
|
||||
assert_eq!(font_label(&app, &menu, designcraft_fonts::DEFAULT_FAMILY), designcraft_fonts::DEFAULT_FAMILY);
|
||||
app.run("prefs.set", json!({"showFontNamesInEnglish": true})).unwrap();
|
||||
assert_eq!(font_label(&app, &font_menu(&app), FAMILY), FAMILY);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -986,7 +986,8 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
|
|||
let sty = c["fontStyle"].as_str().unwrap_or("").to_string();
|
||||
let fw = full_width(ui);
|
||||
// Family dropdown with a search segment on the left.
|
||||
let fams = designcraft_fonts::FontDb::global().families();
|
||||
let fonts = super::fonts(app);
|
||||
let menu = super::font_menu(app);
|
||||
let resp = widgets::dropdown(ui, "", fw);
|
||||
let r = resp.rect;
|
||||
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(3.0, 3.0), vec2(14.0, 14.0)), "search", t.icon);
|
||||
|
|
@ -994,7 +995,7 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
|
|||
ui.painter().text(
|
||||
r.min + vec2(30.0, 10.5),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
if fam.is_empty() { "—" } else { &fam },
|
||||
if fam.is_empty() { "—".to_string() } else { super::font_label(app, &menu, &fam) },
|
||||
egui::FontId::proportional(11.5),
|
||||
t.text,
|
||||
);
|
||||
|
|
@ -1002,21 +1003,19 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
|
|||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(fw);
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
for f in &fams {
|
||||
if ui.selectable_label(*f == fam, f).clicked() {
|
||||
pick_fam = Some(f.clone());
|
||||
ui.close();
|
||||
}
|
||||
if let Some(f) = super::font_menu_rows(app, ui, &menu, &fam) {
|
||||
pick_fam = Some(f);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(f) = pick_fam {
|
||||
let styles = designcraft_fonts::FontDb::global().styles(&f);
|
||||
let styles = fonts.styles(&f);
|
||||
let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
|
||||
let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}}));
|
||||
}
|
||||
ui.add_space(1.0);
|
||||
let styles = designcraft_fonts::FontDb::global().styles(&fam);
|
||||
let styles = fonts.styles(&fam);
|
||||
let cur = styles.iter().position(|s| *s == sty);
|
||||
if let Some(k) = widgets::dropdown_list(ui, if sty.is_empty() { "—" } else { &sty }, fw, &styles, cur) {
|
||||
let _ = app.run("type.char", json!({"attrs": {"fontStyle": styles[k]}}));
|
||||
|
|
@ -1206,6 +1205,8 @@ const LANGUAGES: &[&str] = &[
|
|||
"Turkish",
|
||||
"Japanese",
|
||||
"Chinese",
|
||||
"Chinese: Simplified",
|
||||
"Chinese: Traditional",
|
||||
"Korean",
|
||||
"Arabic",
|
||||
"Hebrew",
|
||||
|
|
@ -2094,7 +2095,7 @@ fn variable_font_axes(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, styl
|
|||
if family.is_empty() {
|
||||
return;
|
||||
}
|
||||
let db = designcraft_fonts::FontDb::global();
|
||||
let db = super::fonts(app);
|
||||
let axes = db.axes(family, style);
|
||||
if axes.is_empty() {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -107,15 +107,19 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) {
|
|||
let c = a["chars"].clone();
|
||||
let fam = c["fontFamily"].as_str().unwrap_or("").to_string();
|
||||
let sty = c["fontStyle"].as_str().unwrap_or("").to_string();
|
||||
let fams = designcraft_fonts::FontDb::global().families();
|
||||
let cur = fams.iter().position(|f| *f == fam);
|
||||
if let Some(k) = widgets::dropdown_list(ui, if fam.is_empty() { "—" } else { &fam }, 150.0, &fams, cur) {
|
||||
let f = fams[k].clone();
|
||||
let styles = designcraft_fonts::FontDb::global().styles(&f);
|
||||
let fonts = panels::fonts(app);
|
||||
let menu = panels::font_menu(app);
|
||||
let english = app.session.prefs.show_font_names_in_english;
|
||||
let labels: Vec<String> = menu.iter().map(|f| f.label(english).to_string()).collect();
|
||||
let cur = menu.iter().position(|f| f.family == fam);
|
||||
let shown = if fam.is_empty() { "—".to_string() } else { panels::font_label(app, &menu, &fam) };
|
||||
if let Some(f) = widgets::dropdown_list(ui, &shown, 150.0, &labels, cur).and_then(|k| menu.get(k)) {
|
||||
let f = f.family.clone();
|
||||
let styles = fonts.styles(&f);
|
||||
let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() };
|
||||
let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}}));
|
||||
}
|
||||
let styles = designcraft_fonts::FontDb::global().styles(&fam);
|
||||
let styles = fonts.styles(&fam);
|
||||
let cur = styles.iter().position(|s| *s == sty);
|
||||
if let Some(k) = widgets::dropdown_list(ui, if sty.is_empty() { "—" } else { &sty }, 90.0, &styles, cur) {
|
||||
let _ = app.run("type.char", json!({"attrs": {"fontStyle": styles[k]}}));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
# CJK typesetting plan
|
||||
|
||||
Goal: the Chinese, Japanese and Korean typesetting that InDesign offers with its East Asian
|
||||
feature set (the "Japanese/CJK" feature set, `Feature Set Locale Setting` 257), so that a
|
||||
DesignCraft document can be set to the standard of a Japanese, Chinese or Korean publisher,
|
||||
round-trip through IDML, and export correct PDF and EPUB.
|
||||
|
||||
It builds on the CJK import and composition described in [cjk-typography.md](cjk-typography.md)
|
||||
(composite fonts, kinsoku sets, aki, tsume, jidori, hanging punctuation, em alignment and leading
|
||||
models, kenten characters, tate-chu-yoko offsets, preserved mojikumi tables) and plans what
|
||||
remains.
|
||||
|
||||
## Sources (clean-room)
|
||||
|
||||
Behaviour comes from public specifications, not from InDesign's implementation:
|
||||
|
||||
- W3C *Requirements for Japanese Text Layout* (JLREQ): character classes, line composition,
|
||||
spacing (aki), line breaking (kinsoku), ruby, kenten, warichu, tate-chu-yoko, grids.
|
||||
- W3C *Requirements for Chinese Text Layout* (CLREQ) and *Korean* (KLREQ): punctuation placement
|
||||
for Simplified and Traditional Chinese, Korean word-based line breaking.
|
||||
- JIS X 4051 (published standard; JLREQ follows it).
|
||||
- OpenType: `vmtx`/`vhea`/`VORG`, `BASE`, the `vert`/`vrt2`/`vkrn`/`vpal`/`palt`/`halt`/`vhal`,
|
||||
`locl`, `ruby`, width (`fwid`/`hwid`/`pwid`/`qwid`/`twid`) and form (`jp78`/`jp83`/`jp90`/`jp04`,
|
||||
`trad`, `expt`, `nlck`, `hojo`) features.
|
||||
- The published IDML specification for attribute names and values.
|
||||
|
||||
InDesign itself is only observed black-box (feature names, which options exist); no wording,
|
||||
presets or tables are copied. Our default mojikumi and kinsoku tables are built from JLREQ.
|
||||
|
||||
## Where we are (2026-10)
|
||||
|
||||
| Area | State |
|
||||
|---|---|
|
||||
| Vertical text, upright CJK, rotated Latin | done: story direction per story (frames of a thread agree; IDML `StoryOrientation`); glyphs turned by transforms; arrow keys follow the vertical lines |
|
||||
| Vertical metrics and shaping | done: upright runs shaped top to bottom (`vert`; `vkrn`/`vpal` when asked) with `vmtx`/`VORG` advances and origins, centred on the ideographic em box (`BASE`, else OS/2); turned runs shaped horizontally, without `vrt2`; Horizontal Kana (`hkna`) takes the vertical kana (`vkna`) in upright runs. Default location of variable fonts only (no `VVAR`) |
|
||||
| Kinsoku | named (hard, soft, Chinese, Korean) and custom kinsoku sets, bunri-kinshi, rensuuji, hanging punctuation (regular, force); without a set, fixed no-start / no-end lists and a break anywhere between CJK characters; kinsoku type (push in / push out priorities) kept but not applied |
|
||||
| Korean line breaking | at spaces (Hangul everywhere, hanja in Korean text); per-paragraph character-based breaking (`koreanCharBreaks`); DesignCraft-only, not in IDML |
|
||||
| Mojikumi | tables and paragraph references kept through IDML; not applied (Preflight says so) |
|
||||
| Aki, tsume, jidori | done ([cjk-typography.md](cjk-typography.md)) |
|
||||
| Character alignment, leading model | em box top / centre / bottom, ICF from ascender and descender, roman baseline; aki above / below, centre (centre down as centre) |
|
||||
| Tate-chu-yoko | manual, with offsets; no auto, 3+ digits overflow the em |
|
||||
| Ruby | group ruby only, fixed 50 % size, no options, doesn't affect leading |
|
||||
| Kenten | the IDML kinds and a custom character, drawn as characters; no position, size, alignment, colour or font |
|
||||
| Composite fonts | done: model, `style.compositeFont.*`, shaping, IDML, font list, replace and Preflight; no dialog |
|
||||
| Languages | Japanese, Korean, Simplified and Traditional Chinese reach the shaper (`locl`), font fallback, line breaking and typographer's quotes; IDML language names are kept as written; locale codes (`ja_JP`, `ko-KR`, `de_DE_2006`) are their language and region, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`) |
|
||||
| Missing glyphs | as in InDesign: characters the applied font lacks are drawn as its missing-glyph box (screen and PDF) and listed by Preflight; the document setting Draw Missing Glyphs from Fallback Fonts (Preferences › Composition, off in new documents and IDML imports, on in documents saved before it existed) draws them from fallback fonts instead |
|
||||
| Font menus | Western families first, then Japanese, Simplified Chinese, Traditional Chinese and Korean groups (from the font's `meta` languages, CJK family names, OS/2 code pages, else its coverage); CJK families under their native names unless Preferences › Type › Show Font Names in English; the order is recalled from InDesign, not observed |
|
||||
| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input, in the font menus for users to apply; fallback by language (with fallback fonts on) (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); document fonts: the fonts of a `Document Fonts` folder beside an opened file belong to that document (composition, export, font menus), ahead of installed fonts of the same name, and are forgotten when it closes; Package copies every font file that draws text, fallback fonts included (licence permitting). Font files stay in memory for the session (reopening a document reuses them) and aren't read on the web |
|
||||
| Everything else below | missing |
|
||||
|
||||
Known bugs to fix first:
|
||||
|
||||
- A justified CJK line has no stretch except Single Word Justification.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Character classes
|
||||
|
||||
A `cjk::class_of(char, &Context) -> Class` in `designcraft-compose` with the JLREQ classes
|
||||
(opening brackets, closing brackets, hyphens, dividing punctuation, middle dots, full stops,
|
||||
commas, inseparable characters, iteration marks, prolonged sound mark, small kana, prefixed and
|
||||
postfixed abbreviations, full-width ideographic space, hiragana, katakana, math symbols and
|
||||
operators, ideographs, Western characters, numerals, ruby bases, warichu brackets, …). Language
|
||||
and the paragraph's punctuation style decide the ambiguous ones (e.g. Chinese full-width
|
||||
punctuation centred vs corner-placed).
|
||||
|
||||
### Spacing: mojikumi
|
||||
|
||||
A *mojikumi set* is a table indexed by (class before, class after) giving the space between them
|
||||
as a min/optimum/max triple in em fractions, plus line-start and line-end rules. Sets are
|
||||
document resources (`Styles::mojikumi_tables`, read from and written to IDML with their override
|
||||
rows), referenced by name from the paragraph's `mojikumi`; what remains is applying them. We
|
||||
ship a few built-in sets derived from JLREQ (no line-start indent, one-em indent, half-width
|
||||
punctuation, full-width punctuation…), all original data, for the IDML built-in names.
|
||||
|
||||
Shaping keeps the glyphs' own advances; mojikumi is applied after shaping as *aki* glue between
|
||||
glyphs: a full-width bracket's empty half becomes negative aki (compression) and the composer
|
||||
can widen or narrow each gap within its range. This keeps `palt`-style proportional fonts
|
||||
working (tsume removes the side bearings before mojikumi runs).
|
||||
|
||||
### Line breaking: kinsoku and the Japanese composer
|
||||
|
||||
- Kinsoku sets (`doc::cjk::Kinsoku`: characters not allowed at line start, at line end, that may
|
||||
hang, and inseparable pairs), the paragraph's set, hanging (none / regular / force),
|
||||
bunri-kinshi and rensuuji are in place: they constrain breaks in both breakers. Remaining: the
|
||||
built-in hard and soft sets from JLREQ's classes (today's named sets are common punctuation
|
||||
lists), and the kinsoku type (push in first / push out first / push out only / prioritise the
|
||||
adjustment amount), which needs the Japanese composer's push-in and push-out candidates.
|
||||
- A `Composer::Japanese` (paragraph) and `Composer::JapaneseSingleLine` beside the Western
|
||||
composers. In `breaker::items` the Japanese composer emits:
|
||||
- boxes per glyph,
|
||||
- mojikumi glue between glyphs with priority tiers (compress punctuation first, then
|
||||
expand inter-ideograph space, then Western/CJK spacing, then letter-spacing),
|
||||
- kinsoku penalties (infinite inside prohibited pairs; push-in candidates scored by
|
||||
compression needed),
|
||||
- hanging punctuation as zero-width boxes allowed past the measure.
|
||||
Knuth–Plass already takes tiered stretch (`TIER_COST`), so this plugs into `kp_pass`.
|
||||
- Justification in `layout_line` distributes by the same tiers; the last line follows the
|
||||
paragraph's last-line alignment; CJK lines justify by default (JLREQ).
|
||||
- Korean: the Japanese composer with word-based breaking (spaces) by default; per-paragraph
|
||||
option for character-based breaking.
|
||||
|
||||
### Vertical text, properly
|
||||
|
||||
- Read `vmtx`, `vhea`, `VORG` and the ideographic em box (`BASE` `ideo`/`idtp`, else OS/2 typo
|
||||
metrics). `FontFace` gets `v_advance(gid)`, `v_origin(gid)` and `em_box()`.
|
||||
- Shape upright runs with direction `TopToBottom` so `vert`, `vkrn` and `vpal` apply and the
|
||||
shaper reports vertical advances; rotated (Latin) runs stay horizontal and turn as a whole.
|
||||
`vrt2` only for runs that are rotated, never with an extra turn.
|
||||
- Glyph placement in vertical lines uses the vertical origin, not a guessed centre.
|
||||
- Story direction per story (IDML `StoryOrientation`); frames of one story agree.
|
||||
- Arrow keys and carets follow the vertical line direction.
|
||||
|
||||
### Annotations and inline structures
|
||||
|
||||
All set after line layout like today's ruby, but able to affect line height:
|
||||
|
||||
- **Ruby**: per-character (mono), group and jukugo ruby; alignment (1-2-1, centre, left/right,
|
||||
equal space, JIS rule), position (above/right, below/left), size, font, colour, x/y scale,
|
||||
offsets, overhang onto neighbours (none / ½ / 1 ruby character / ½ base / unlimited), automatic
|
||||
scaling, spacing to the base, "ruby adjusts leading".
|
||||
- **Kenten**: position, size, alignment, colour, font (the kinds and a custom character are
|
||||
there).
|
||||
- **Warichu**: an inline run set in 2+ smaller lines inside one line height, with size, line
|
||||
spacing, alignment and breaking rules.
|
||||
- **Tate-chu-yoko**: scale-to-fit-em, automatic TCY for runs of N digits (with or without
|
||||
Latin), rensuji (offsets are there).
|
||||
- **Shatai** (oblique in the CJK sense: angle and magnification, keeping the em box),
|
||||
**character rotation**.
|
||||
- **Character alignment and leading model**: ICF top and bottom from the font's ideographic
|
||||
character face (`BASE` `icft`/`icfb`) rather than its ascender and descender, and centre down
|
||||
as its own model.
|
||||
|
||||
Jidori, tsume, aki before and after, em-box alignment and the other leading models are in place
|
||||
([cjk-typography.md](cjk-typography.md)).
|
||||
|
||||
### Grids
|
||||
|
||||
- **Layout grid** (per page / parent): character size, characters per line, lines per column,
|
||||
columns and gutter, origin; drawn like the baseline grid; snap target.
|
||||
- **Frame grids**: text frames that show a character grid (horizontal or vertical), with a
|
||||
character count, named grids as document resources (font, size, scale, aki between characters
|
||||
and lines, alignment, view options), grid alignment of paragraphs (em-box centre, ICF top/bottom,
|
||||
roman baseline, gyoudori: lines spanning N grid lines), grid tracking.
|
||||
- Frame Grid tools (horizontal and vertical) and Object › Frame Type (text frame ↔ frame grid).
|
||||
|
||||
### Fonts
|
||||
|
||||
- **Composite fonts** (a named font made of a base font plus fonts for character sets, with size,
|
||||
baseline, x/y scale and centre adjustments) are resolved before shaping, read from and written
|
||||
to IDML and listed, replaced and preflighted by their member fonts; PDF embeds the member fonts.
|
||||
Remaining: the Composite Fonts dialog, and the predefined character classes (kanji, kana,
|
||||
full-width symbols, punctuation, Latin, numerals) beside custom sets.
|
||||
- **Missing glyphs**: InDesign doesn't substitute fonts per character: a character the applied
|
||||
font lacks shows as the font's missing-glyph box, prints that way and is a Preflight error.
|
||||
DesignCraft does the same, with a document setting (Draw Missing Glyphs from Fallback Fonts)
|
||||
that draws such characters from fallback fonts instead.
|
||||
- **Language-aware fallback** (when that setting is on): Japanese, Simplified Chinese, Traditional
|
||||
Chinese and Korean each have their own fallback chain of system fonts (Japanese: the craft-fonts
|
||||
Japanese faces first); `locl` follows the character language (IDML language names).
|
||||
- **No CJK fonts in the repo.** CJK fonts are large (15–25 MB each even subset) and documents
|
||||
should name the fonts they use. The Japanese UI and document faces come from the optional
|
||||
craft-fonts build input when DesignCraft is built with it (release builds are).
|
||||
- **Document fonts**: fonts in a `Document Fonts` folder beside an opened `.designcraft` or
|
||||
`.idml` file are available to that document only, ahead of system fonts, and unloaded when it
|
||||
closes. File › Package copies the document's fonts into the package's `Document Fonts` folder
|
||||
(font licences permitting: OS/2 `fsType` restricted-licence fonts are listed, not copied), so a
|
||||
packaged CJK document opens with its fonts on another machine. On the web, the fonts of a
|
||||
dropped or opened package folder load the same way.
|
||||
|
||||
### Editing and output
|
||||
|
||||
- Find/Change: kana-sensitive and width-sensitive matching; full/half-width and
|
||||
hiragana/katakana conversion (Type › Change Case).
|
||||
- Numbering styles: kanji, full-width, circled, parenthesised.
|
||||
- Typographer's quotes per language (Simplified/Traditional Chinese, Korean).
|
||||
- Character count (Info panel) by CJK conventions.
|
||||
- EPUB/HTML: `writing-mode: vertical-rl`, `<ruby>`, `text-combine-upright`, `text-emphasis`.
|
||||
- PDF: correct `ActualText` for ruby/warichu; vertical glyph placement as above.
|
||||
- IDML: every attribute below, import and export, round-trip tested.
|
||||
|
||||
## Feature set (UI)
|
||||
|
||||
Preferences › Type gets a *Feature Set* choice (Western, CJK). CJK shows the East Asian
|
||||
controls in the Character and Paragraph panels, styles dialogs and menus (Kinsoku, Mojikumi,
|
||||
Composite Fonts, Named Grids, Layout Grid), makes the Japanese composer the default for new
|
||||
documents and offers the frame grid tools. Western hides them; documents keep their settings
|
||||
either way (opening a CJK document in Western mode loses nothing). Every feature stays reachable
|
||||
as a command regardless of the feature set (agents, MCP).
|
||||
|
||||
## IDML attributes to support
|
||||
|
||||
Already read and written: `KentenKind`, `KentenCustomCharacter`, `RubyFlag`, `RubyString`,
|
||||
`Tatechuyoko`, `TatechuyokoXOffset`, `TatechuyokoYOffset`, `Jidori`, `Tsume`, `LeadingAki`,
|
||||
`TrailingAki`, `CharacterAlignment`, `LeadingModel`, `GlyphForm`, `OTFProportionalMetrics`,
|
||||
`OTFHVKana`, `OTFRomanItalics`; paragraph `KinsokuSet`, `KinsokuType`, `KinsokuHangType`,
|
||||
`BunriKinshi`, `Rensuuji`, `MojikumiTable`, `TreatIdeographicSpaceAsSpace`; resources
|
||||
`KinsokuTable`, `MojikumiTable`, `CompositeFont`, `StoryPreference` direction.
|
||||
|
||||
Character: `KentenFont`, `KentenFontStyle`, `KentenSize`, `KentenPosition`, `KentenAlignment`,
|
||||
`KentenCharacterSet`, `KentenXScale`, `KentenYScale`, `KentenFillColor`/`Tint`, `RubyType`,
|
||||
`RubyAlignment`, `RubyPosition`, `RubyFont`, `RubyFontStyle`, `RubyFontSize`, `RubyXScale`,
|
||||
`RubyYScale`, `RubyXOffset`, `RubyYOffset`, `RubyOverhang`, `RubyParentSpacing`,
|
||||
`RubyParentOverhangAmount`, `RubyAutoAlign`, `RubyAutoScaling`, `RubyAutoTcyDigits`, `Rensuji`,
|
||||
`CharacterRotation`, `ShataiMagnification`, `ShataiDegreeAngle`, `ShataiAdjustRotation`,
|
||||
`ShataiAdjustTsume`, `Warichu`, `WarichuLines`, `WarichuSize`, `WarichuLineSpacing`,
|
||||
`WarichuAlignment`, `WarichuCharsBeforeBreak`, `WarichuCharsAfterBreak`.
|
||||
|
||||
Paragraph: `Composer` (the Japanese composers), `GridAlignment` (all values),
|
||||
`GridAlignFirstLineOnly`, `GridGyoudori`, `ParagraphGyoudori`, `CjkGridTracking`, `AutoTcy`,
|
||||
`AutoTcyIncludeRoman`, `RotateSingleByteCharacters`, `ScaleAffectsLineHeight`.
|
||||
|
||||
Resources: `MojikumiTableSet`, `NamedGrid`, layout grid and frame grid preferences,
|
||||
`StoryPreference` orientation.
|
||||
|
||||
Exact names and enumerations are checked against the IDML specification as each phase lands.
|
||||
|
||||
## Phases
|
||||
|
||||
Each phase lands as reviewable commits with tests (compose tests for layout, engine tests for
|
||||
commands, IDML round trips, a rendered sample page) and `cargo xtask ci`.
|
||||
|
||||
| # | Phase | Contents | Estimate (agent hours) |
|
||||
|---|---|---|---|
|
||||
| 1 | Foundations | language passed to the shaper (`locl`); Simplified/Traditional Chinese and Korean languages; language-aware CJK fallback; document fonts folder; Korean word breaking; `vmtx`/`VORG`/em box and vertical shaping; story-level direction; vertical caret keys | 12–17 |
|
||||
| 2 | Japanese composer | character classes; mojikumi applied (built-in sets and the document's tables) as aki glue; JLREQ kinsoku sets and kinsoku types (push in / push out); Japanese paragraph and single-line composers; CJK justification and last line; Chinese punctuation styles; IDML | 20–30 |
|
||||
| 3 | Inline CJK | full ruby, kenten options, warichu, auto TCY and scale-to-fit, rensuji, shatai, character rotation, ICF alignment from the font; IDML | 20–30 |
|
||||
| 4 | Grids | layout grid, frame grids and tools, named grids, grid alignment and gyoudori, grid tracking, character count; IDML | 20–30 |
|
||||
| 5 | Fonts | Composite Fonts dialog and predefined character classes; CJK OpenType features in the OpenType menu | 5–8 |
|
||||
| 6 | Editing and output | kana/width-sensitive Find/Change, width and kana conversion, CJK numbering, quotes, EPUB/HTML vertical text and ruby | 10–15 |
|
||||
| 7 | Feature set UI | Preferences › Feature Set, panels and dialogs for every attribute, Japanese defaults for new documents, translations | 15–20 |
|
||||
|
||||
Total: roughly 100–150 agent hours.
|
||||
|
||||
### Phase 1 in detail
|
||||
|
||||
1. `CharProps.language` reaches `designcraft_fonts::shape` (harfrust buffer language) and the
|
||||
word cache key; tests: a font with `locl` for JAN vs ZHS draws different glyphs for U+76F4.
|
||||
2. Language list: "Chinese: Simplified", "Chinese: Traditional", "Korean", "Japanese" (IDML
|
||||
names), with quotes and spelling/hyphenation off for them.
|
||||
3. Fallback chains by language in `FontDb::fallback_for` (system fonts per platform; the
|
||||
craft-fonts Japanese faces first for Japanese); tests with stand-in fonts and a fake catalog.
|
||||
4. Document fonts: opening a document loads the fonts in its `Document Fonts` folder for that
|
||||
document; Package copies used fonts there (licence permitting) and reports the ones it
|
||||
couldn't copy; tests with a synthetic package folder.
|
||||
5. Korean: `cjk_break_between` doesn't break between Hangul syllables unless the paragraph asks
|
||||
for character breaking; test with a Korean sentence.
|
||||
6. `FontFace::v_advance/v_origin/em_box` from `vmtx`/`vhea`/`VORG`/`BASE`; upright glyphs in
|
||||
vertical lines use them; tests against synthetic fonts with and without `vmtx` (without, the
|
||||
em box), and Shippori Mincho's tables when built with craft-fonts.
|
||||
7. Upright runs shaped top-to-bottom (`vert`, `vkrn`, `vpal` apply); `vrt2` never on turned
|
||||
runs; test that a full-width comma takes its vertical form and position.
|
||||
8. Story direction at story level (frames of a story agree; IDML maps both ways); arrow keys move
|
||||
along the vertical line.
|
||||
|
||||
## Decisions
|
||||
|
||||
Each follows what an InDesign user expects.
|
||||
|
||||
1. **Feature set gating.** East Asian controls are hidden in the Western feature set and shown in
|
||||
the CJK feature set, as in InDesign. Every feature stays reachable as a command either way.
|
||||
2. **Korean line breaking** is word-based by default (KLREQ), with a per-paragraph option to break
|
||||
between syllables.
|
||||
3. **PDF vertical text** keeps transformed horizontal glyphs (correct-looking, copyable via
|
||||
ActualText) rather than vertical CID fonts (`Identity-V`).
|
||||
4. **No CJK fonts in the repo**; documents carry theirs in `Document Fonts`, scoped to the
|
||||
document as in InDesign. DesignCraft embeds Japanese UI and document faces only when built with
|
||||
the optional craft-fonts input (release builds are).
|
||||
5. **Package copies every font that draws text**, fallback fonts included (when the document
|
||||
draws missing glyphs from them), so a package reproduces what is on screen.
|
||||
6. **Korean fallback is serif first** (Myungjo), as Chinese and Japanese fall back to Song and
|
||||
Mincho: the serif faces CJK editions of layout software default to.
|
||||
7. **Language names on IDML import** accept the common spellings of Simplified and Traditional
|
||||
Chinese, since the exact names InDesign writes are unconfirmed without a sample file.
|
||||
8. **Missing glyphs** are drawn as the font's box and reported, as in InDesign; drawing them from
|
||||
fallback fonts is a per-document choice, on only for documents saved before it existed.
|
||||
Loading…
Reference in New Issue