diff --git a/ROADMAP.md b/ROADMAP.md index 3a62ae4..6f60cf4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,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); 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). 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, `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); 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). @@ -185,7 +185,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 | diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 1b46c70..43eb9e1 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -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 { 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)) } } @@ -1960,7 +1974,7 @@ 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, } diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index a3d0cfe..8bd1bc9 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -61,6 +61,9 @@ pub struct Glyph { 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 { @@ -99,7 +102,8 @@ pub struct SubstCtx { pub objects: HashMap, /// Conditions currently hidden (conditional text with only these isn't shown). pub hidden_conditions: Vec, - /// 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, } @@ -619,10 +623,11 @@ fn shape_run_raw( } type WordMap = HashMap, Arc<[ShapedGlyph]>>; -type WordKey = (u32, bool, String, Option<&'static str>); +type WordKey = (u32, bool, bool, String, Option<&'static str>); -/// Shaped words (font units) by (face, caps, features, language), 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>)>> = RwLock::new(Vec::new()); const WORD_CACHE_MAX: usize = 50_000; @@ -645,13 +650,28 @@ fn word_map(key: WordKey) -> Arc> { /// 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], lang: Option<&'static str>, caps: bool, around: (&str, &str)) -> Vec { +fn shape_cached( + face: &FontFace, + src: &str, + feats: &[Feature], + lang: Option<&'static str>, + caps: bool, + vertical: bool, + (before, after): (&str, &str), +) -> Vec { + 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) { - let (before, after) = around; - return designcraft_fonts::shape_with_context(face, src, feats, map, designcraft_fonts::ShapeContext { before, after, language: lang }); + return shape(src, ShapeContext { before, after, language: lang }); } - let words = word_map((face.id(), caps, format!("{feats:?}"), lang)); + let words = word_map((face.id(), vertical, caps, format!("{feats:?}"), lang)); let pieces: Vec<&str> = src.split_inclusive(' ').collect(); let mut found: Vec>> = { let r = words.read().unwrap_or_else(|e| e.into_inner()); @@ -660,14 +680,7 @@ fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], lang: Option<&'st let mut fresh: Vec<(Box, 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: lang, ..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); } @@ -755,6 +768,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty safe_tatweel_before: false, shaping_rtl: false, lang: designcraft_doc::language_tag(&p.language), + upright: false, } } @@ -778,23 +792,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; // 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); - let around = if replacement.is_none() { (text.get(..range.start).unwrap_or(""), text.get(range.end..).unwrap_or("")) } else { ("", "") }; - let shaped: Vec = shape_cached(face, src, &feats, lang, caps, around); + // 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 = Vec::new(); + let mut upright: Vec = 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 { @@ -803,7 +831,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 { @@ -821,8 +856,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, @@ -855,10 +890,26 @@ fn shape_segment( 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, bool)> { + let mut runs: Vec<(std::ops::Range, 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}']); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index c88cfa8..faa3708 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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] @@ -1589,3 +1603,158 @@ fn korean_lines_keep_kinsoku() { } } } + +/// 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.vertical = true; + tf.options.inset = [0.0; 4]; + } + 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::>()); + (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::>(), [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::>()); + 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); + } +} diff --git a/docs/cjk.md b/docs/cjk.md index 50ceb5a..6955571 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -32,7 +32,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Area | State | |---|---| | Vertical frames, upright CJK, rotated Latin | done (per frame; glyphs turned by transforms) | -| `vert`/`vrt2` in vertical frames | done (applied to every run, Latin included); Horizontal Kana (`hkna`) becomes `vkna` | +| 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) | @@ -47,8 +47,6 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built Known bugs to fix first: -- Vertical advances are the horizontal ones and upright glyphs turn about a guessed centre - (`adv × 0.38`), so proportional and non-square glyphs sit wrong in vertical text. - A justified CJK line has no stretch except Single Word Justification. ## Architecture