diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index c4aad577c..c9c7bae82 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -200,6 +200,8 @@ pub fn parse_page_range(s: &str, count: usize) -> Result> { Ok(out) } +#[cfg(test)] +mod rtl_tests; #[cfg(test)] mod tests; diff --git a/crates/pdf/src/rtl_tests.rs b/crates/pdf/src/rtl_tests.rs new file mode 100644 index 000000000..fc38747a7 --- /dev/null +++ b/crates/pdf/src/rtl_tests.rs @@ -0,0 +1,190 @@ +//! 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. + +use designcraft_compose::{Cache, ComposeOptions, compose_story}; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, SpreadRef, TextDirection}; +use designcraft_geom::Rect; + +use crate::tests::extract_text; +use crate::*; + +const HEBREW: &str = "DC Test PDF Hebrew"; +const ARABIC: &str = "DC Test PDF Arabic"; + +/// A stand-in Hebrew font: each letter and point has a glyph of its own (Latin capitals and +/// combining accents of the bundled face), so the ToUnicode map is unambiguous. +fn hebrew_font() { + let letters = ['ד', 'ו', 'ש', 'ר', 'ה', 'י', 'א', 'ל', 'ב', 'ם', 'צ', 'ח', 'ק', 'ע']; + let points = ['\u{5B7}', '\u{5B0}', '\u{5B8}', '\u{5B4}', '\u{5B2}', '\u{5B9}']; + let accents = ['\u{300}', '\u{301}', '\u{302}', '\u{303}', '\u{304}', '\u{308}']; + let mut map: Vec<(char, char)> = letters.iter().zip('A'..).map(|(h, l)| (*h, l)).collect(); + map.extend(points.iter().copied().zip(accents)); + map.extend(" &abcdef".chars().map(|c| (c, c))); + designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_mapping(HEBREW, &map).expect("font")); +} + +/// A stand-in Arabic font with a tatweel, so justification inserts kashidas. +fn arabic_font() { + let map = [('ب', 'b'), ('س', 's'), ('م', 'm'), ('ا', 'a'), ('ل', 'l'), ('ه', 'h'), ('\u{640}', '_'), (' ', ' ')]; + designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_mapping(ARABIC, &map).expect("font")); +} + +/// One frame of `text` in `family` with paragraph attributes `para`. +fn doc(text: &str, family: &str, r: Rect, para: ParaAttrs) -> Document { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), r, lid, text, ParaFormat { para, ..ParaFormat::default() }).unwrap(); + let sid = *d.stories.keys().next().unwrap(); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(family.into())); + d +} + +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 { + use hayro_syntax::content::ops::TypedInstruction; + use hayro_syntax::object::{Dict, Object}; + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); + let page = &pdf.pages()[0]; + let mut out = Vec::new(); + let mut ops = page.typed_operations(); + while let Some(op) = ops.next() { + 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::(b"ActualText") else { continue }; + let units: Vec = 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"); + out.push(text.trim_start_matches('\u{FEFF}').to_string()); + } + out +} + +#[test] +fn hebrew_extracts_in_reading_order_with_points_on_their_letters() { + hebrew_font(); + let lines = ["דוד & שרה", "ישראל אַבְרָהָם יִצְחָק וְיַעֲקֹב", "דוד abc שרה"]; + let d = doc(&lines.join("\n"), HEBREW, Rect::new(36.0, 36.0, 576.0, 300.0), rtl()); + 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. + let tagged = export_pdf(&d, &Cache::new(), &PdfOptions { tagged: true, ..PdfOptions::default() }).unwrap(); + assert_eq!(line_actual_texts(&tagged), lines); + assert_eq!(extract_text(&tagged)[0], lines.concat()); +} + +#[test] +fn hebrew_words_in_left_to_right_lines_extract_in_reading_order() { + hebrew_font(); + let text = "abc דוד & שרה def"; + 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()); +} + +/// Device origin of every glyph drawn on the first page. +fn glyph_origins(bytes: &[u8]) -> Vec { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + struct Rec(Vec); + impl Device<'_> for Rec { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, g: &Glyph<'_>, xf: kurbo::Affine, gxf: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) { + if let Glyph::Outline(_) = g { + self.0.push((xf * gxf) * kurbo::Point::ZERO); + } + } + fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); + let cache = InterpreterCache::new(); + let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); + let mut rec = Rec(Vec::new()); + interpret_page(&pdf.pages()[0], &mut ctx, &mut rec); + rec.0 +} + +#[test] +fn pointed_letters_are_drawn_where_composed() { + hebrew_font(); + let text = "יִצְחָק אַבְרָהָם\nיִצְחָק אַבְרָהָם"; + let mut d = doc(text, HEBREW, Rect::new(36.0, 36.0, 576.0, 300.0), rtl()); + let sid = *d.stories.keys().next().unwrap(); + // The second paragraph is skewed: points keep their place relative to their letters. + let second = text.find('\n').unwrap() + 1; + d.story_mut(sid).unwrap().format_chars(second..text.len(), |f| f.over.skew = Some(12.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let composed: Vec = + cs.frames[0].lines.iter().flat_map(|l| l.glyphs.iter().filter(|g| g.visible).map(|g| kurbo::Point::new(g.x, l.baseline + g.y))).collect(); + assert!(composed.iter().any(|p| (p.y - composed[0].y).abs() > 1.0 && (p.y - composed[0].y).abs() < 20.0), "the points sit off the baseline"); + let drawn = glyph_origins(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap()); + assert_eq!(drawn.len(), composed.len()); + // The page places the frame (y grows upwards on the page): match the two through their means. + let mean = |ps: &[kurbo::Point]| ps.iter().fold(kurbo::Vec2::ZERO, |a, p| a + p.to_vec2()) / ps.len() as f64; + let (c, k) = (mean(&composed), mean(&drawn)); + let key = |x: f64, y: f64| ((x * 100.0).round() as i64, (y * 100.0).round() as i64); + let mut want: Vec<(i64, i64)> = composed.iter().map(|p| key(p.x - c.x, c.y - p.y)).collect(); + let mut got: Vec<(i64, i64)> = drawn.iter().map(|p| key(p.x - k.x, p.y - k.y)).collect(); + want.sort_unstable(); + got.sort_unstable(); + let off: Vec<_> = want.iter().zip(&got).filter(|(w, g)| (w.0 - g.0).abs() > 1 || (w.1 - g.1).abs() > 1).collect(); + assert!(off.is_empty(), "{off:?}"); +} + +#[test] +fn kashidas_extract_as_no_text() { + arabic_font(); + let text = "بسم الله بسم الله بسم الله بسم الله بسم الله بسم الله بسم الله"; + let para = ParaAttrs { align: Some(Align::FullyJustified), ..rtl() }; + let d = doc(text, ARABIC, Rect::new(36.0, 36.0, 186.0, 300.0), para); + let sid = *d.stories.keys().next().unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let kashida = |g: &designcraft_compose::PlacedGlyph| g.len == 0 && g.gid == g.face.glyph_for('\u{640}'); + assert!(cs.frames[0].lines.iter().any(|l| l.glyphs.iter().any(kashida)), "justification inserts kashidas"); + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let words: Vec = 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:?}"); + let spans = line_actual_texts(&bytes); + assert!(spans.len() > 1 && spans.iter().all(|t| !t.contains('\u{640}') && text.contains(t.trim())), "{spans:?}"); +} + +#[test] +fn arabic_ligatures_extract_their_letters() { + // Lam-alef in a system font with Arabic (skipped without one). + let text = "لا سلام"; + let mut d = doc(text, 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 = &cs.frames[0].lines[0].glyphs; + if glyphs.iter().any(|g| g.gid == 0) || !glyphs.iter().any(|g| g.byte == 0 && g.len == "لا".len()) { + return; + } + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert_eq!(extract_text(&bytes)[0], text); + assert_eq!(line_actual_texts(&bytes), [text]); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index bf59efded..e500f8bce 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -1,10 +1,14 @@ //! Composed text → PDF text objects with embedded (subsetted) fonts. //! -//! Each line is split into runs of glyphs sharing face, style, scale and baseline offset. A run is -//! drawn with one `draw_glyphs` call: the run origin, horizontal scale and skew go into the -//! transform, glyph advances are taken from the composed x positions (so justification, tracking -//! and kerning are exact), and every glyph carries the story text it came from so viewers can -//! select, search and copy it. +//! Each line is split into runs of glyphs sharing face, style and scale. A run is drawn with one +//! `draw_glyphs` call: the run origin, horizontal scale and skew go into the transform, glyph +//! advances and offsets are taken from the composed positions (so justification, tracking, kerning +//! and mark positioning are exact), and every glyph carries the story text it came from so viewers +//! 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. use std::collections::HashMap; use std::ops::Range; @@ -36,7 +40,83 @@ fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool { && a.style == b.style && (a.sx - b.sx).abs() < 1e-9 && (a.sy - b.sy).abs() < 1e-9 - && (a.y - b.y).abs() < 1e-6 +} + +/// End of the run of `gs` that starts at `i`. +fn run_end(ft: &FrameText, gs: &[PlacedGlyph], i: usize) -> usize { + let mut j = i + 1; + while j < gs.len() && same_run(&gs[i], &gs[j]) && !(ft.vertical && (gs[j].upright || gs[j].tcy.is_some())) { + j += 1; + } + j +} + +/// Clusters of several glyphs (a base and its marks, as in pointed Hebrew) map glyph by glyph +/// when every glyph stands for one of the cluster's characters, and are drawn in text order: +/// viewers that only read ToUnicode then get each character once, marks after their base. A +/// glyph stands for a character when it is the font's glyph for it, or when both are marks (a +/// mark's glyph may be a positional variant). Other clusters (ligatures, contextual forms) keep +/// one shared range, which krilla writes as ActualText. Returns the order to draw the glyphs in. +fn split_clusters(glyphs: &[PlacedGlyph], text: &str, ranges: &mut [Range]) -> Vec { + let mut order: Vec = (0..glyphs.len()).collect(); + let mut i = 0; + while i < ranges.len() { + let r = ranges[i].clone(); + let mut j = i + 1; + while ranges.get(j).is_some_and(|n| *n == r) { + j += 1; + } + if let (Some(cluster), Some(members)) = (text.get(r.clone()).filter(|_| j - i > 1), glyphs.get(i..j)) + && let Some(own) = cluster_chars(members, cluster) + { + for (m, (at, c)) in own.into_iter().enumerate() { + if let Some(range) = ranges.get_mut(i + m) { + *range = r.start + at..r.start + at + c.len_utf8(); + } + } + if let Some(o) = order.get_mut(i..j) { + o.sort_by_key(|&m| ranges.get(m).map_or(0, |r| r.start)); + } + } + i = j; + } + order +} + +/// The character (byte offset in `cluster`, char) each glyph of a cluster stands for, if each +/// stands for a different one and all are covered. +fn cluster_chars(members: &[PlacedGlyph], cluster: &str) -> Option> { + let chars: Vec<(usize, char)> = cluster.char_indices().collect(); + if chars.len() != members.len() { + return None; + } + let mut own: Vec> = vec![None; members.len()]; + let mut used = vec![false; chars.len()]; + for (m, g) in members.iter().enumerate() { + if let Some(k) = (0..chars.len()).find(|&k| !used[k] && g.face.glyph_for(chars[k].1) == g.gid) { + used[k] = true; + own[m] = Some(k); + } + } + // The remaining glyphs and characters must all be marks; they pair up in text order (a + // right-to-left cluster lists its glyphs right to left). + let mut rest: Vec = (0..members.len()).filter(|&m| own[m].is_none()).collect(); + if members.first().is_some_and(|g| g.rtl) { + rest.reverse(); + } + let marks: Vec = (0..chars.len()).filter(|&k| !used[k]).collect(); + if rest.len() != marks.len() { + return None; + } + for (&m, &k) in rest.iter().zip(&marks) { + let g = members.get(m)?; + let c = chars.get(k)?.1; + if !g.face.glyph_is_mark(g.gid) || !g.face.glyph_is_mark(g.face.glyph_for(c)) { + return None; + } + own[m] = Some(k); + } + own.into_iter().map(|k| chars.get(k?).copied()).collect() } /// Merge bars of one paint layer; underlines and strikes must never be merged together. @@ -143,6 +223,7 @@ impl Exporter<'_> { } for l in &ft.lines { let gs = &l.glyphs; + let line_span = self.start_rtl_line(s, cs, ft, gs, story); let mut i = 0; while i < gs.len() { let g = &gs[i]; @@ -160,13 +241,17 @@ impl Exporter<'_> { i += 1; continue; } - let mut j = i + 1; - while j < gs.len() && same_run(g, &gs[j]) && !(ft.vertical && (gs[j].upright || gs[j].tcy.is_some())) { - j += 1; + 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.as_artifact(s, |me, s| { for (rule, r) in text_rules(cs, ft, false) { @@ -184,6 +269,47 @@ 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 { @@ -263,6 +389,10 @@ impl Exporter<'_> { } else if g.len == 0 && prev_src == Some(g.byte) && !ranges.is_empty() { // Second glyph of a multi-glyph cluster: share the cluster's text. ranges[ranges.len() - 1].clone() + } else if g.len == 0 && cmap.get(&g.gid) == Some(&'\u{0640}') { + // A kashida that justification inserted: drawn, but no text of its own. + prev_src = None; + text.len()..text.len() } else { // Inserted glyphs (page numbers, list labels, hyphens): map back through the font. prev_src = None; @@ -298,33 +428,40 @@ impl Exporter<'_> { let face = g0.face; let size = g0.sy * face.upem; let hs = g0.sx / g0.sy; - let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let tan = if st.skew != 0.0 { st.skew.to_radians().tan() } else { 0.0 }; + let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -tan, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0); // A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text. if g0.gid == 0 { - self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight); + self.outline_run(s, glyphs, origin, tan, size, fill, stroke, st.stroke_weight); return; } let Some(font) = self.font(&face) else { self.warn(format!("font `{} {}` could not be embedded; its text was drawn as outlines", face.family, face.style)); - self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight); + self.outline_run(s, glyphs, origin, tan, size, fill, stroke, st.stroke_weight); return; }; - let (text, ranges) = self.run_text(glyphs, story); + let (text, mut ranges) = self.run_text(glyphs, story); + let order = split_clusters(glyphs, &text, &mut ranges); let k = 1.0 / (hs * size); - let kg: Vec = glyphs + // 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 = order .iter() - .zip(ranges) .enumerate() - .map(|(i, (g, r))| { - let next = glyphs.get(i + 1).map_or(g.x + g.adv, |n| n.x); - KrillaGlyph::new(GlyphId::new(g.gid), ((next - g.x) * k) as f32, 0.0, 0.0, 0.0, r, None) + .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() })); - s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false); + 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(); @@ -337,6 +474,7 @@ impl Exporter<'_> { s: &mut Surface, glyphs: &[PlacedGlyph], origin: Affine, + tan: f64, size: f64, fill: Option, stroke: Option, @@ -347,7 +485,8 @@ impl Exporter<'_> { let k = size / g0.face.upem; let mut bp = BezPath::new(); for g in glyphs { - let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k); + let dy = g.y - g0.y; + let a = Affine::translate(((g.x - g0.x + tan * dy) / (g0.sx / g0.sy), dy)) * Affine::scale(k); let mut o = (*db.outline(&g.face, g.gid)).clone(); o.apply_affine(a); bp.extend(o.iter());