From 4c4dedaa93092609ee8f8d00b686d4e0909aa371 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 16:02:52 +0000 Subject: [PATCH] Ruby: clears kenten on the same side, stays with the kana it overhangs, overprints in Overprint Preview --- crates/compose/src/ruby.rs | 44 +++++++++++++ crates/compose/src/shape.rs | 15 ++++- crates/compose/src/tests.rs | 99 ++++++++++++++++++++++++++++++ crates/engine/src/cmd/preflight.rs | 7 ++- crates/pdf/src/tests.rs | 15 +++++ crates/pdf/src/text.rs | 3 + crates/render/src/text.rs | 13 ++++ 7 files changed, 193 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/ruby.rs b/crates/compose/src/ruby.rs index e761f1689..548e00701 100644 --- a/crates/compose/src/ruby.rs +++ b/crates/compose/src/ruby.rs @@ -450,6 +450,24 @@ pub(crate) fn reserve(db: &ScopedFonts<'_>, styles: &mut Vec, glyphs: g.adv += trail; } } + // The overhang this relies on needs the neighbour on the same line: at a line edge the + // edge takes none (auto-align) or only the overhang amount, so the group stays with each + // neighbour its ruby overhangs and the spacing reserved here is what `annotate` places. + let spaced: f64 = glyphs.get(i..j).unwrap_or_default().iter().map(|g| g.adv).sum(); + let excess = w - spaced; + if excess > 1e-9 { + let lead = lead_overhang(spec.alignment, excess, before, after); + if lead > 1e-9 + && let Some(g) = i.checked_sub(1).and_then(|k| glyphs.get_mut(k)) + { + g.break_after = Some(false); + } + if excess - lead > 1e-9 + && let Some(g) = j.checked_sub(1).and_then(|k| glyphs.get_mut(k)) + { + g.break_after = Some(false); + } + } // Group ruby keeps its parent on one line (JLReq 3.3). if !mono { for g in glyphs.get_mut(first..last).unwrap_or_default() { @@ -503,6 +521,31 @@ fn ruby_style(styles: &mut Vec, base: &RunStyle, spec: &RubySpec, size intern(styles, rs) } +/// How far the ruby must move away from its parent (down for ruby below, up for ruby above: the +/// result is signed along y) so that it clears kenten set on the same side of the parent +/// characters. Kenten sit next to the parent (JIS X 4051, JLReq: emphasis dots go between the +/// base characters and ruby on the same side); the ruby's em box then starts where the marks' em +/// box ends. `y` is the ruby's baseline without kenten and `tall` its em height. +fn kenten_clearance(styles: &[RunStyle], base: &[&PlacedGlyph], shaped: &Shaped, tall: f64, position: RubyPosition, y: f64) -> f64 { + let below = position == RubyPosition::BelowLeft; + let (ruby_top, ruby_bottom) = shaped.face.em_box(); + let k = tall / shaped.face.units_per_em().max(1.0); + let overlap = base + .iter() + .filter_map(|b| { + let m = styles.get(b.style as usize)?.kenten_mark.as_ref().filter(|m| m.below == below)?; + let (top, bottom) = b.face.em_box(); + let h = m.size * m.y_scale; + Some(if below { + (b.y - bottom * b.sy + m.distance + h) - (y - ruby_top * k) + } else { + (y - ruby_bottom * k) - (b.y - top * b.sy - m.distance - h) + }) + }) + .fold(0.0, f64::max); + if below { overlap } else { -overlap } +} + /// Add the ruby of a laid-out line's glyphs. `text` is the story text the glyphs' /// bytes index; `glyph_fallback`: the document draws missing glyphs from fallback fonts (a /// missing font's substitute always does); `vertical`: the frame is vertical. @@ -594,6 +637,7 @@ pub(crate) fn annotate( RubyPosition::AboveRight => -(st.size * 0.88 + tall * 0.2) - spec.y_offset, RubyPosition::BelowLeft => st.size * 0.12 + tall * 0.96 + spec.y_offset, }; + let y = y + kenten_clearance(styles, &base, &shaped, tall, spec.position, y); let missing = if spec.font_family.is_empty() { st.missing_font } else { !db.has_family(&spec.font_family) }; let style = ruby_style(styles, &st, &spec, shaped.size, missing); for (r, off) in shaped.glyphs.iter().zip(offsets) { diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 89ad0eab0..2c4037042 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -157,7 +157,15 @@ impl StyleTable<'_> { fn intern(&mut self, db: &ScopedFonts<'_>, p: &CharProps) -> u32 { // Kenten are drawn in a run style of their own (Kenten Color); it has no kenten itself. let kenten_mark = if p.kenten { - let style = self.intern(db, &crate::kenten::mark_props(p)); + use designcraft_doc::cjk_settings::AdornmentOverprint as O; + let mut style = self.intern(db, &crate::kenten::mark_props(p)); + // Text has no overprint of its own, so `Auto` is off. + let (fill, stroke) = (p.kenten_overprint_fill == O::On, p.kenten_overprint_stroke == O::On); + if (fill || stroke) + && let Some(base) = self.styles.get(style as usize) + { + style = self.push_unique(RunStyle { overprint_fill: fill, overprint_stroke: stroke, ..base.clone() }); + } Some(crate::kenten::KentenMark::of(p, style)) } else { None @@ -204,6 +212,11 @@ impl StyleTable<'_> { kenten_mark, shatai: crate::shatai::Shatai::of(p), }; + self.push_unique(rs) + } + + /// The index of `rs` in the table, added if it isn't there yet. + fn push_unique(&mut self, rs: RunStyle) -> u32 { if let Some(i) = self.styles.iter().rposition(|s| *s == rs) { return i as u32; } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e43e004d3..d0ea3505c 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1155,6 +1155,105 @@ fn ruby_is_half_size_by_default_and_can_sit_below_in_its_own_colour() { assert_eq!(cs.styles[l.glyphs[0].style as usize].fill, "[Black]"); } +#[test] +fn ruby_moves_out_past_kenten_on_the_same_side() { + use designcraft_doc::cjk_settings::KentenPosition; + use designcraft_doc::ruby::RubyPosition; + let kanji = 0.."漢字".len(); + // The ruby glyphs' baseline, and the outer edge of the kenten's em box (y down). + let compose = |ruby: RubyPosition, kenten: Option| { + let (plain, l, cs) = ruby_compose("漢字です", kanji.clone(), |a| { + a.ruby = Some("かんじ".into()); + a.ruby_position = Some(ruby); + if let Some(k) = kenten { + a.kenten = Some(true); + a.kenten_size = Some(Some(6.0)); + a.kenten_distance = Some(1.0); + a.kenten_position = Some(k); + } + }); + let parent = &l.glyphs[0]; + let marks = cs.styles[parent.style as usize].kenten_mark.as_ref().map(|m| m.style); + let ruby: Vec = l.glyphs[plain.len()..].iter().filter(|g| Some(g.style) != marks).cloned().collect(); + assert_eq!(ruby.len(), 3); + let (top, bottom) = parent.face.em_box(); + let edge = match kenten { + Some(KentenPosition::BelowLeft) => parent.y - bottom * parent.sy + 1.0 + 6.0, + _ => parent.y - top * parent.sy - 1.0 - 6.0, + }; + (ruby, edge) + }; + for (position, kenten) in [(RubyPosition::AboveRight, KentenPosition::AboveRight), (RubyPosition::BelowLeft, KentenPosition::BelowLeft)] { + let (alone, _) = compose(position, None); + let (ruby, edge) = compose(position, Some(kenten)); + for (r, a) in ruby.iter().zip(&alone) { + let (top, bottom) = r.face.em_box(); + if position == RubyPosition::AboveRight { + assert!(r.y - bottom * r.sy <= edge + 1e-6, "the ruby's em box ends where the marks' starts"); + assert!(r.y < a.y, "moved up"); + } else { + assert!(r.y - top * r.sy >= edge - 1e-6, "the ruby's em box starts where the marks' ends"); + assert!(r.y > a.y, "moved down"); + } + } + } + // Kenten on the other side leave the ruby where it was. + let (alone, _) = compose(RubyPosition::AboveRight, None); + let (ruby, _) = compose(RubyPosition::AboveRight, Some(KentenPosition::BelowLeft)); + assert!(ruby.iter().zip(&alone).all(|(r, a)| (r.y - a.y).abs() < 1e-9)); +} + +#[test] +fn ruby_and_kenten_overprint_their_own_run_styles() { + use designcraft_doc::cjk_settings::AdornmentOverprint as O; + let kanji = 0.."漢字".len(); + let (plain, l, cs) = ruby_compose("漢字です", kanji, |a| { + a.ruby = Some("かんじ".into()); + a.ruby_overprint_stroke = Some(O::On); + a.kenten = Some(true); + a.kenten_overprint_fill = Some(O::On); + }); + let parent = &cs.styles[l.glyphs[0].style as usize]; + assert!(!parent.overprint_fill && !parent.overprint_stroke, "the text itself doesn't overprint"); + let marks = parent.kenten_mark.as_ref().unwrap().style; + let mark = &cs.styles[marks as usize]; + assert!(mark.overprint_fill && !mark.overprint_stroke); + let ruby = l.glyphs[plain.len()..].iter().find(|g| g.style != marks).unwrap(); + let ruby = &cs.styles[ruby.style as usize]; + assert!(!ruby.overprint_fill && ruby.overprint_stroke); +} + +#[test] +fn longer_ruby_never_overhangs_a_line_edge_or_more_than_its_neighbours_take() { + // Four 6 pt ruby characters over one 12 pt kanji: 12 pt longer, which the kana on each side + // take (one ruby character, 6 pt, each). A line edge takes none, so the group stays on the + // line of each kana it overhangs. + let text = "かな字".repeat(12); + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 190.0, 500.0), lid, &text, ParaFormat::default()).unwrap(); + for (k, _) in text.match_indices('字') { + d.story_mut(sid).unwrap().format_chars(k..k + "字".len(), |f| f.over.ruby = Some("じじじじ".into())); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = &cs.frames[0].lines; + assert!(lines.len() > 2, "the text wraps"); + for l in lines { + let parents: Vec<&PlacedGlyph> = l.glyphs.iter().filter(|g| g.len > 0 && g.visible).collect(); + let x0 = parents.iter().map(|g| g.x - g.dx).fold(f64::MAX, f64::min); + let x1 = parents.iter().map(|g| g.x - g.dx + g.adv).fold(f64::MIN, f64::max); + for p in parents.iter().filter(|g| text.get(g.byte..).is_some_and(|t| t.starts_with('字'))) { + let ruby: Vec<&PlacedGlyph> = l.glyphs.iter().filter(|g| g.len == 0 && g.byte == p.byte).collect(); + assert_eq!(ruby.len(), 4); + let r0 = ruby.iter().map(|g| g.x).fold(f64::MAX, f64::min); + let r1 = ruby.iter().map(|g| g.x + g.adv).fold(f64::MIN, f64::max); + assert!(r0 >= x0 - 1e-6 && r1 <= x1 + 1e-6, "inside the line: {r0}..{r1} in {x0}..{x1}"); + let (p0, p1) = (p.x - p.dx, p.x - p.dx + p.adv); + assert!(p0 - r0 <= 6.0 + 1e-6 && r1 - p1 <= 6.0 + 1e-6, "at most one ruby character over each kana: {r0}..{r1} over {p0}..{p1}"); + } + } +} + #[test] fn warichu_stacks_the_run_inside_the_line_and_closes_up() { let text = "ABCDEFGHZ"; diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index b24c695b3..1516c75d9 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -118,8 +118,11 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { let props = d.styles.resolve_char(&base, f); { use designcraft_doc::cjk_settings::AdornmentOverprint as O; - if props.kenten && (props.kenten_overprint_fill != O::Auto || props.kenten_overprint_stroke != O::Auto) { - unsupported_typography.insert("Kenten overprint is preserved, but not applied".to_string()); + let kenten = props.kenten && (props.kenten_overprint_fill == O::On || props.kenten_overprint_stroke == O::On); + let ruby = !props.ruby.is_empty() && (props.ruby_overprint_fill == O::On || props.ruby_overprint_stroke == O::On); + if kenten || ruby { + unsupported_typography + .insert("Ruby and kenten overprint show in Overprint Preview, but PDF export doesn't write overprint".to_string()); } } if has_rtl diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d057..de3c236c6 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -446,3 +446,18 @@ fn column_rules_are_drawn() { assert!((xs[0] - 214.0).abs() < 0.01 && (xs[1] - 398.0).abs() < 0.01, "{xs:?}"); assert!(rules(false).is_empty()); } + +#[test] +fn ruby_overprint_is_reported_as_not_exported() { + use designcraft_doc::cjk_settings::AdornmentOverprint; + let mut d = doc_with_text("漢字です"); + let sid = *d.stories.keys().next().unwrap(); + let clean = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(!clean.warnings.iter().any(|w| w.contains("overprint")), "{:?}", clean.warnings); + d.story_mut(sid).unwrap().format_chars(0.."漢字".len(), |f| { + f.over.ruby = Some("かんじ".into()); + f.over.ruby_overprint_fill = Some(AdornmentOverprint::On); + }); + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("overprint is not exported yet")), "{:?}", r.warnings); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 97b90a828..d1b2083ac 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -297,6 +297,9 @@ impl Exporter<'_> { if fill.is_none() && stroke.is_none() { return; } + if st.overprint_fill || st.overprint_stroke { + self.warn("overprint is not exported yet"); + } let face = g0.face; let size = g0.sy * face.upem; let hs = g0.sx / g0.sy; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 3e349a1a4..3396760f5 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -383,9 +383,22 @@ impl Renderer { if st.stroke != designcraft_color::swatch::NONE && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) { + let op = crate::overprints(f, &st.stroke, st.stroke_tint, st.overprint_stroke); + if op { + ctx.push_layer( + None, + Some(vello_cpu::peniko::BlendMode::new(vello_cpu::peniko::Mix::Multiply, vello_cpu::peniko::Compose::SrcOver)), + None, + None, + None, + ); + } ctx.set_paint(color_of(&c, 1.0)); ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); ctx.stroke_path(bp); + if op { + ctx.pop_layer(); + } } } }