Ruby: clears kenten on the same side, stays with the kana it overhangs, overprints in Overprint Preview

This commit is contained in:
aiongg
2026-10-10 16:02:52 +00:00
parent b720ff6147
commit 4c4dedaa93
7 changed files with 193 additions and 3 deletions
+44
View File
@@ -450,6 +450,24 @@ pub(crate) fn reserve(db: &ScopedFonts<'_>, styles: &mut Vec<RunStyle>, 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<RunStyle>, 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) {
+14 -1
View File
@@ -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;
}
+99
View File
@@ -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<KentenPosition>| {
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<PlacedGlyph> = 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";
+5 -2
View File
@@ -118,8 +118,11 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
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
+15
View File
@@ -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);
}
+3
View File
@@ -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;
+13
View File
@@ -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();
}
}
}
}