CJK 1.6: upright text is shaped top to bottom
In vertical frames upright runs are shaped TopToBottom (vert; vkrn/vpal when asked), advance by their vertical metrics and hang from their vertical origins on the em box centre, on screen and in PDF; their line ascent and descent are the em box. Turned runs stay horizontal without vrt2 (rather than every run taking vert and vrt2), and tate-chu-yoko stays horizontal and centres on the em box, its offsets moving it along and across the line. Horizontal Kana in upright runs takes vkna. Each run is shaped with the text around it, as horizontal runs are.
This commit is contained in:
parent
1602b37ec9
commit
1574dcdd45
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
}
|
||||
|
||||
|
|
@ -619,10 +623,11 @@ fn shape_run_raw(
|
|||
}
|
||||
|
||||
type WordMap = HashMap<Box<str>, 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<Vec<(WordKey, Arc<RwLock<WordMap>>)>> = RwLock::new(Vec::new());
|
||||
const WORD_CACHE_MAX: usize = 50_000;
|
||||
|
||||
|
|
@ -645,13 +650,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], lang: Option<&'static str>, caps: bool, around: (&str, &str)) -> 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) {
|
||||
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<Option<Arc<[ShapedGlyph]>>> = {
|
||||
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<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: 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<FontFace>, 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<ShapedGlyph> = 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<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 {
|
||||
|
|
@ -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<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}']);
|
||||
|
|
|
|||
|
|
@ -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::<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue