PDF: glyphs in a right-to-left line's span open no ActualText span of their own

krilla wraps a mark cluster that maps to several glyphs, and a glyph drawn
with other text than its font already maps it to, in an ActualText span.
Inside a line's ActualText span that nested the spans, and poppler drops
the text before a nested span. Inside a line span each glyph now gets the
text its font maps it to (the exporter keeps a copy of krilla's mappings),
a cluster's text goes to its first glyph, and a run splits into separate
draw calls where two glyphs in a row would share an empty range. A run
drawn through tagged_run inside a line span takes the span as its tag.
Test: mark_clusters_in_right_to_left_lines_open_no_span_of_their_own
(5 nested spans before).
This commit is contained in:
aiongg
2026-10-11 04:45:20 +00:00
parent 2b96dffda1
commit a81fb0be62
3 changed files with 145 additions and 18 deletions
+11
View File
@@ -162,6 +162,8 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
story_tags: HashMap::new(),
tag_story: None,
para_tags: HashMap::new(),
in_line_span: false,
to_unicode: HashMap::new(),
};
if ex.rgb_only {
ex.warn("PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)");
@@ -339,6 +341,8 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R
story_tags: HashMap::new(),
tag_story: None,
para_tags: HashMap::new(),
in_line_span: false,
to_unicode: HashMap::new(),
};
let pairs = booklet_pairs(n, opts.kind);
// Sheet size from the largest page.
@@ -470,6 +474,11 @@ pub(crate) struct Exporter<'a> {
pub(crate) tag_story: Option<designcraft_doc::StoryId>,
/// Marked content of each (story, paragraph index).
pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec<Identifier>>,
/// While a line's ActualText span is open (no marked content may open inside it).
pub(crate) in_line_span: bool,
/// The text krilla maps each embedded glyph to. It keeps a glyph's first mapping and wraps a
/// glyph drawn with other text in an ActualText span.
pub(crate) to_unicode: HashMap<(krilla::text::Font, u32), String>,
}
enum TagEntry {
@@ -1579,6 +1588,8 @@ mod image_content_cache_tests {
story_tags: HashMap::new(),
tag_story: None,
para_tags: HashMap::new(),
in_line_span: false,
to_unicode: HashMap::new(),
}
}
#[test]
+46
View File
@@ -168,6 +168,7 @@ fn kashidas_extract_as_no_text() {
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let words: Vec<String> = extract_text(&bytes)[0].split([' ', '\u{FFFD}']).filter(|w| !w.is_empty()).map(str::to_string).collect();
assert!(words.iter().all(|w| w == "بسم" || w == "الله"), "{words:?}");
assert_eq!(opened_inside_actual_text(&bytes), 0);
let spans = line_actual_texts(&bytes);
assert!(spans.len() > 1 && spans.iter().all(|t| !t.contains('\u{640}') && text.contains(t.trim())), "{spans:?}");
}
@@ -188,3 +189,48 @@ fn arabic_ligatures_extract_their_letters() {
assert_eq!(extract_text(&bytes)[0], text);
assert_eq!(line_actual_texts(&bytes), [text]);
}
/// How many marked-content sections the first page opens while an ActualText span is open.
/// Viewers such as poppler drop the text before an ActualText span nested in another.
fn opened_inside_actual_text(bytes: &[u8]) -> usize {
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::Object;
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
let page = &pdf.pages()[0];
let (mut open, mut nested) = (Vec::new(), 0);
let mut ops = page.typed_operations();
while let Some(op) = ops.next() {
let actual = match op {
TypedInstruction::BeginMarkedContentWithProperties(op) => matches!(op.1, Object::Dict(d) if d.contains_key(b"ActualText")),
TypedInstruction::BeginMarkedContent(_) => false,
TypedInstruction::EndMarkedContent(_) => {
open.pop();
continue;
}
_ => continue,
};
nested += usize::from(open.contains(&true));
open.push(actual);
}
nested
}
#[test]
fn mark_clusters_in_right_to_left_lines_open_no_span_of_their_own() {
// Shadda clusters on contextual forms and pointed letters, in a system font with Arabic and
// Hebrew (skipped without one): krilla gives such clusters an ActualText span of their own.
let lines = ["سيّد الكلّ مُحَمَّد", "ישראל אַבְרָהָם יִצְחָק"];
let mut d = doc(&lines.join("\n"), designcraft_fonts::DEFAULT_FAMILY, Rect::new(36.0, 36.0, 576.0, 300.0), rtl());
d.settings.glyph_fallback = true;
let sid = *d.stories.keys().next().unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
let glyphs: Vec<_> = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).collect();
if glyphs.iter().any(|g| g.gid == 0) || !glyphs.windows(2).any(|w| w[1].len == 0 && w[1].byte == w[0].byte) {
return;
}
for tagged in [false, true] {
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { tagged, ..PdfOptions::default() }).unwrap();
assert_eq!(opened_inside_actual_text(&bytes), 0, "tagged: {tagged}");
assert_eq!(line_actual_texts(&bytes), lines, "tagged: {tagged}");
}
}
+88 -18
View File
@@ -184,17 +184,18 @@ impl Exporter<'_> {
}
}
/// A glyph run, tagged under its paragraph while a story is being tagged.
/// A glyph run, tagged under its paragraph while a story is being tagged (inside a line span,
/// the span is its tag: marked content can't nest).
fn tagged_run(&mut self, s: &mut Surface, cs: &ComposedStory, gs: &[PlacedGlyph], baseline: f64, story: &str) {
match self.tag_story {
Some(sid) => {
Some(sid) if !self.in_line_span => {
let pi = self.doc.story(sid).map_or(0, |st| st.para_at(gs[0].byte.min(st.len())));
let id = s.start_tagged(ContentTag::Span(SpanTag::empty()));
self.run(s, cs, gs, baseline, story);
s.end_tagged();
self.para_tags.entry((sid, pi)).or_default().push(id);
}
None => self.run(s, cs, gs, baseline, story),
_ => self.run(s, cs, gs, baseline, story),
}
}
@@ -224,6 +225,7 @@ impl Exporter<'_> {
for l in &ft.lines {
let gs = &l.glyphs;
let line_span = self.start_rtl_line(s, cs, ft, gs, story);
self.in_line_span = line_span;
let mut i = 0;
while i < gs.len() {
let g = &gs[i];
@@ -251,6 +253,7 @@ impl Exporter<'_> {
}
if line_span {
s.end_tagged();
self.in_line_span = false;
}
}
self.as_artifact(s, |me, s| {
@@ -443,30 +446,89 @@ impl Exporter<'_> {
};
let (text, mut ranges) = self.run_text(glyphs, story);
let order = split_clusters(glyphs, &text, &mut ranges);
let (text, ranges, cuts) = if self.in_line_span {
self.line_span_text(&font, glyphs, &order, &text, &ranges)
} else {
self.note_unicode(&font, order.iter().filter_map(|&i| Some((glyphs.get(i)?.gid, ranges.get(i)?.clone()))), &text);
(text, ranges, Vec::new())
};
let k = 1.0 / (hs * size);
// Glyphs are placed absolutely: each advance reaches the next glyph drawn, a vertical offset
// (a mark above or below its base) is undone by the skew so it lands where it was composed.
let kg: Vec<KrillaGlyph> = order
.iter()
.enumerate()
.filter_map(|(n, &i)| {
let g = glyphs.get(i)?;
let next = order.get(n + 1).and_then(|&m| glyphs.get(m)).map_or(g.x + g.adv, |m| m.x);
let dy = g.y - g0.y;
let r = ranges.get(i)?.clone();
Some(KrillaGlyph::new(GlyphId::new(g.gid), ((next - g.x) * k) as f32, (tan * dy * k) as f32, (-dy / size) as f32, 0.0, r, None))
})
.collect();
let start = order.first().and_then(|&i| glyphs.get(i)).map_or(0.0, |g| (g.x - g0.x) / hs);
let glyph = |n: usize| {
let g = glyphs.get(*order.get(n)?)?;
let next = order.get(n + 1).and_then(|&m| glyphs.get(m)).map_or(g.x + g.adv, |m| m.x);
let dy = g.y - g0.y;
let r = ranges.get(*order.get(n)?)?.clone();
Some(KrillaGlyph::new(GlyphId::new(g.gid), ((next - g.x) * k) as f32, (tan * dy * k) as f32, (-dy / size) as f32, 0.0, r, None))
};
s.push_transform(&tf(origin));
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() }));
s.draw_glyphs(Point::from_xy(start as f32, 0.0), &kg, font, &text, size as f32, false);
let mut from = 0;
for to in cuts.into_iter().chain([order.len()]) {
let kg: Vec<KrillaGlyph> = (from..to).filter_map(glyph).collect();
let start = order.get(from).and_then(|&i| glyphs.get(i)).map_or(0.0, |g| (g.x - g0.x) / hs);
s.draw_glyphs(Point::from_xy(start as f32, 0.0), &kg, font.clone(), &text, size as f32, false);
from = to;
}
s.set_fill(None);
s.set_stroke(None);
s.pop();
}
/// Record what krilla maps the glyphs of one draw call to, given in drawing order: the first
/// text a glyph is drawn with, except on the glyphs of a cluster after its first. (krilla maps
/// every glyph of a cluster only for PDF/A-a, -u and PDF/UA, which exports don't use.)
fn note_unicode(&mut self, font: &Font, glyphs: impl IntoIterator<Item = (u32, Range<usize>)>, text: &str) {
let mut prev = None;
for (gid, r) in glyphs {
if prev.as_ref() != Some(&r)
&& let Some(t) = text.get(r.clone())
{
self.to_unicode.entry((font.clone(), gid)).or_insert_with(|| t.to_string());
}
prev = Some(r);
}
}
/// Text for a run drawn inside a line span, where krilla must not open ActualText spans of its
/// own (it does for a glyph drawn with other text than its font maps it to, and for glyphs in a
/// row that share their text). Each glyph gets the text its font maps it to, else its own text,
/// a cluster's text going to its first glyph. Returns the text, each glyph's range in it, and
/// the positions in `order` where the run must be split into separate draw calls because two
/// glyphs in a row have the same empty range.
fn line_span_text(
&mut self,
font: &Font,
glyphs: &[PlacedGlyph],
order: &[usize],
text: &str,
ranges: &[Range<usize>],
) -> (String, Vec<Range<usize>>, Vec<usize>) {
let mut out = String::new();
let mut out_ranges = vec![0..0; glyphs.len()];
let mut cuts = Vec::new();
let (mut prev, mut prev_out) = (None, None);
for (n, &i) in order.iter().enumerate() {
let (Some(g), Some(r)) = (glyphs.get(i), ranges.get(i)) else { continue };
let own = if prev == Some(r) { "" } else { text.get(r.clone()).unwrap_or_default() };
let mapped = self.to_unicode.entry((font.clone(), g.gid)).or_insert_with(|| own.to_string());
let a = out.len();
out.push_str(mapped);
let range = a..out.len();
if prev_out.as_ref() == Some(&range) {
cuts.push(n);
}
if let Some(slot) = out_ranges.get_mut(i) {
*slot = range.clone();
}
prev = Some(r);
prev_out = Some(range);
}
(out, out_ranges, cuts)
}
/// Fallback for faces krilla can't embed: glyph outlines as paths.
#[allow(clippy::too_many_arguments)]
fn outline_run(
@@ -507,23 +569,31 @@ impl Exporter<'_> {
let Some(font) = self.font(&face) else { return };
let shaped = designcraft_fonts::shape(&face, text, &[], |c| c);
let upem = face.upem as f32;
let glyphs: Vec<KrillaGlyph> = shaped
let ranges: Vec<(u32, Range<usize>)> = shaped
.iter()
.enumerate()
.map(|(i, g)| {
let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|c| *c != g.cluster).unwrap_or(text.len());
let end = if end > g.cluster { end } else { text.len() };
(g.gid, g.cluster..end)
})
.collect();
let glyphs: Vec<KrillaGlyph> = shaped
.iter()
.zip(&ranges)
.map(|(g, (_, r))| {
KrillaGlyph::new(
GlyphId::new(g.gid),
g.x_advance as f32 / upem,
g.x_offset as f32 / upem,
g.y_offset as f32 / upem,
0.0,
g.cluster..end,
r.clone(),
None,
)
})
.collect();
self.note_unicode(&font, ranges, text);
s.set_stroke(None);
s.set_fill(Some(solid_fill(color, 1.0)));
s.draw_glyphs(Point::from_xy(at.0 as f32, at.1 as f32), &glyphs, font, text, size as f32, false);