PDF: right-to-left lines carry no line-wide ActualText

poppler positions an ActualText with the transform in force where its
span ends; a line span closes outside the runs' transforms, so pdftotext
placed the line text off the page and dropped it. An ActualText per run
fares no better: poppler reverses its characters in a right-to-left
line. Without them, poppler orders the glyphs of right-to-left lines
itself and extracts whole lines in reading order, and the content stream
keeps listing them in reading order. With no line span left, glyphs in
it no longer need to avoid krilla's cluster spans, so that workaround
goes too. The tests that read the line span's ActualText now check that
ActualText never spans more than a cluster.
This commit is contained in:
aiongg
2026-10-11 05:33:12 +00:00
parent a81fb0be62
commit 8f30ba1c55
3 changed files with 37 additions and 173 deletions
-11
View File
@@ -162,8 +162,6 @@ 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)");
@@ -341,8 +339,6 @@ 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.
@@ -474,11 +470,6 @@ 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 {
@@ -1588,8 +1579,6 @@ 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]
+14 -22
View File
@@ -1,6 +1,6 @@
//! Right-to-left text in exported PDFs: content streams list it in reading order with marks after
//! their letters, lines with right-to-left text carry their reading-order text as ActualText, and
//! glyphs stay where composition put them.
//! their letters, ActualText never spans more than a cluster, and glyphs stay where composition
//! put them.
use designcraft_compose::{Cache, ComposeOptions, compose_story};
use designcraft_doc::build::NewDocument;
@@ -45,8 +45,8 @@ fn rtl() -> ParaAttrs {
ParaAttrs { direction: Some(TextDirection::RightToLeft), ..ParaAttrs::default() }
}
/// The ActualText of every line span (marked content with an MCID) on the first page.
fn line_actual_texts(bytes: &[u8]) -> Vec<String> {
/// The ActualText of every marked-content span on the first page.
fn actual_texts(bytes: &[u8]) -> Vec<String> {
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object};
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
@@ -57,9 +57,6 @@ fn line_actual_texts(bytes: &[u8]) -> Vec<String> {
let TypedInstruction::BeginMarkedContentWithProperties(op) = op else { continue };
let Object::Dict(props) = op.1 else { continue };
let props: &Dict<'_> = props;
if !props.contains_key(b"MCID") {
continue;
}
let Some(s) = props.get::<hayro_syntax::object::String>(b"ActualText") else { continue };
let units: Vec<u16> = s.as_bytes().chunks_exact(2).map(|b| u16::from_be_bytes([b[0], b[1]])).collect();
let text = String::from_utf16(&units).expect("UTF-16");
@@ -76,11 +73,9 @@ fn hebrew_extracts_in_reading_order_with_points_on_their_letters() {
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
// Glyph by glyph (ToUnicode, content order): each point right after its letter, once.
assert_eq!(extract_text(&bytes)[0], lines.concat());
// Viewers that order text by position read each line's ActualText.
assert_eq!(line_actual_texts(&bytes), lines);
// Tagged, the span is the line's tag under its paragraph.
assert!(actual_texts(&bytes).is_empty());
let tagged = export_pdf(&d, &Cache::new(), &PdfOptions { tagged: true, ..PdfOptions::default() }).unwrap();
assert_eq!(line_actual_texts(&tagged), lines);
assert!(actual_texts(&tagged).is_empty());
assert_eq!(extract_text(&tagged)[0], lines.concat());
}
@@ -91,10 +86,7 @@ fn hebrew_words_in_left_to_right_lines_extract_in_reading_order() {
let d = doc(text, HEBREW, Rect::new(36.0, 36.0, 576.0, 300.0), ParaAttrs::default());
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(extract_text(&bytes)[0], text);
assert_eq!(line_actual_texts(&bytes), [text]);
// A line without right-to-left text has no line span.
let d = doc("abc def", HEBREW, Rect::new(36.0, 36.0, 576.0, 300.0), ParaAttrs::default());
assert!(line_actual_texts(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap()).is_empty());
assert!(actual_texts(&bytes).is_empty());
}
/// Device origin of every glyph drawn on the first page.
@@ -168,9 +160,8 @@ 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:?}");
let spans = actual_texts(&bytes);
assert!(spans.iter().all(|t| !t.contains('\u{640}') && !t.contains(' ')), "{spans:?}");
}
#[test]
@@ -187,7 +178,6 @@ fn arabic_ligatures_extract_their_letters() {
}
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
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.
@@ -216,9 +206,10 @@ fn opened_inside_actual_text(bytes: &[u8]) -> usize {
}
#[test]
fn mark_clusters_in_right_to_left_lines_open_no_span_of_their_own() {
fn right_to_left_lines_carry_actual_text_only_on_their_clusters() {
// 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.
// Hebrew (skipped without one): krilla gives such clusters an ActualText span of their own,
// which must not nest in another (poppler drops the text before a nested one).
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;
@@ -231,6 +222,7 @@ fn mark_clusters_in_right_to_left_lines_open_no_span_of_their_own() {
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}");
let spans = actual_texts(&bytes);
assert!(!spans.is_empty() && spans.iter().all(|t| !t.contains(' ') && t.chars().count() <= 3), "tagged: {tagged}: {spans:?}");
}
}
+23 -140
View File
@@ -7,8 +7,10 @@
//! can select, search and copy it.
//!
//! Composed lines hold their glyphs in text order, so content streams list text in reading order
//! even where right-to-left text is drawn leftwards. Viewers that order text by position instead
//! read the ActualText that a line with right-to-left text carries.
//! even where right-to-left text is drawn leftwards. Right-to-left lines and runs carry no
//! ActualText of their own: poppler orders right-to-left glyphs by itself, but it positions an
//! ActualText with the transform in force where its span ends (losing a span over several runs)
//! and reverses the characters of one in a right-to-left line.
use std::collections::HashMap;
use std::ops::Range;
@@ -184,18 +186,17 @@ impl Exporter<'_> {
}
}
/// 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).
/// A glyph run, tagged under its paragraph while a story is being tagged.
fn tagged_run(&mut self, s: &mut Surface, cs: &ComposedStory, gs: &[PlacedGlyph], baseline: f64, story: &str) {
match self.tag_story {
Some(sid) if !self.in_line_span => {
Some(sid) => {
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);
}
_ => self.run(s, cs, gs, baseline, story),
None => self.run(s, cs, gs, baseline, story),
}
}
@@ -224,8 +225,6 @@ 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];
@@ -244,17 +243,9 @@ impl Exporter<'_> {
continue;
}
let j = run_end(ft, gs, i);
if line_span {
self.run(s, cs, &gs[i..j], l.baseline, story);
} else {
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
}
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
i = j;
}
if line_span {
s.end_tagged();
self.in_line_span = false;
}
}
self.as_artifact(s, |me, s| {
for (rule, r) in text_rules(cs, ft, false) {
@@ -272,47 +263,6 @@ impl Exporter<'_> {
});
}
/// Open a span over a line with right-to-left text whose ActualText is the line's text in
/// reading order (tagged under its paragraph while a story is being tagged). Viewers that order
/// text by position would otherwise read it in visual order. Marked content can't nest, so only
/// in untagged exports and in tagged stories (not inside artifacts or figures).
fn start_rtl_line(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, gs: &[PlacedGlyph], story: &str) -> bool {
if ft.vertical || !gs.iter().any(|g| g.rtl && g.visible) || (self.opts.tagged && self.tag_story.is_none()) {
return false;
}
let mut text = String::new();
let mut i = 0;
while i < gs.len() {
let g = &gs[i];
if !g.visible || g.sx <= 0.0 || g.sy <= 0.0 {
i += 1;
continue;
}
let j = run_end(ft, gs, i);
// Only runs `run` draws.
let painted = cs.styles.get(g.style as usize).is_some_and(|st| {
self.swatch_color(&st.fill, st.fill_tint).is_some()
|| (st.stroke != designcraft_color::swatch::NONE
&& st.stroke_weight > 0.0
&& self.swatch_color(&st.stroke, st.stroke_tint).is_some())
});
if painted && let Some(run) = gs.get(i..j) {
text.push_str(&self.run_text(run, story).0);
}
i = j;
}
if text.is_empty() {
return false;
}
let id = s.start_tagged(ContentTag::Span(SpanTag::empty().with_actual_text(Some(&text))));
if let Some(sid) = self.tag_story {
let byte = gs.first().map_or(0, |g| g.byte);
let pi = self.doc.story(sid).map_or(0, |st| st.para_at(byte.min(st.len())));
self.para_tags.entry((sid, pi)).or_default().push(id);
}
true
}
/// Table fragments: cell fills, cell text (real text), edges and the border.
fn tables(&mut self, s: &mut Surface, ft: &FrameText) {
for t in &ft.tables {
@@ -446,89 +396,30 @@ 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 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))
};
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);
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() }));
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.draw_glyphs(Point::from_xy(start as f32, 0.0), &kg, font, &text, size as f32, false);
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(
@@ -569,31 +460,23 @@ 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 ranges: Vec<(u32, Range<usize>)> = shaped
let glyphs: Vec<KrillaGlyph> = 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,
r.clone(),
g.cluster..end,
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);