From c512ec189aea44be49ddafb62b316201e15535af Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 2 Oct 2026 12:14:57 +0200 Subject: [PATCH] fix pdf export --- render-wasm/src/shapes/text_vertical/paint.rs | 38 ++++++++- .../src/shapes/text_vertical/shaping.rs | 84 ++++++++++++++++--- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/render-wasm/src/shapes/text_vertical/paint.rs b/render-wasm/src/shapes/text_vertical/paint.rs index 48dda0d9da..679dd833f2 100644 --- a/render-wasm/src/shapes/text_vertical/paint.rs +++ b/render-wasm/src/shapes/text_vertical/paint.rs @@ -192,7 +192,7 @@ fn ruby_draws(layout: &VerticalLayout, ruby: &RubyCell, origin: (f32, f32)) -> V .zip(&ruby.glyph_tops) .filter_map(|(ruby_glyph, top)| { let run = &layout.ruby_runs[ruby_glyph.run]; - let blob = single_glyph_blob(&run.font, *run.glyphs.get(ruby_glyph.glyph)?)?; + let blob = run.glyph_blob(ruby_glyph.glyph)?; let (_, metrics) = run.font.metrics(); let advance = run .advances @@ -761,4 +761,40 @@ mod tests { ); } } + + /// Uncompressed PDF of the layout's fill pass, as Latin-1 text. + fn layout_pdf(layout: &VerticalLayout, bounds: &Rect) -> String { + let mut bytes: Vec = Vec::new(); + let metadata = skia::pdf::Metadata { + compression_level: skia::pdf::CompressionLevel::None, + ..Default::default() + }; + { + let document = skia::pdf::new_document(&mut bytes, Some(&metadata)); + let mut page = document.begin_page((400.0, 400.0), None); + paint_layout(page.canvas(), layout, bounds, VerticalAlign::Top); + page.end_page().close(); + } + bytes.iter().map(|byte| *byte as char).collect() + } + + #[test] + fn pdf_text_of_vertical_forms_is_the_source_text() { + let content = make_content(&["「あ」"], 400.0); + let layout = layout_with(&provider(VPAL_TEST_FONT), &content); + + let pdf = layout_pdf(&layout, &content.bounds()).to_uppercase(); + let to_unicode = pdf + .split_once("BEGINBFCHAR") + .and_then(|(_, rest)| rest.split_once("ENDBFCHAR")) + .map(|(entries, _)| entries) + .expect("a ToUnicode CMap"); + + // The vertical alternates have no cmap entry of their own; the PDF + // text maps them back to the source characters. + for source in ["<300C>", "<3042>", "<300D>"] { + assert!(to_unicode.contains(source), "ToUnicode maps {source}"); + } + assert!(!to_unicode.contains("<0000>"), "no glyph maps to U+0000"); + } } diff --git a/render-wasm/src/shapes/text_vertical/shaping.rs b/render-wasm/src/shapes/text_vertical/shaping.rs index 3a00bb2faf..20c7484c31 100644 --- a/render-wasm/src/shapes/text_vertical/shaping.rs +++ b/render-wasm/src/shapes/text_vertical/shaping.rs @@ -38,6 +38,8 @@ pub struct ShapedRun { pub clusters: Vec, pub advance: f32, pub utf8_range: Range, + /// Source text of `utf8_range`, attached to the run's text blobs. + pub text: String, /// Local-y offset that centres the run's ink after 90° rotation. pub rotated_baseline_shift: f32, } @@ -87,22 +89,77 @@ impl ShapedRun { glyph_run_ink_bounds(&self.font, &self.glyphs, &self.positions) } + /// Byte range in `text` of the clusters covering `glyph..glyph + count`. + fn text_range(&self, glyph: usize, count: usize) -> Range { + let base = self.utf8_range.start as u32; + let clusters = &self.clusters[glyph..glyph + count]; + let first = clusters.iter().copied().min().unwrap_or(base); + let last = clusters.iter().copied().max().unwrap_or(base); + let end = self + .clusters + .iter() + .copied() + .filter(|cluster| *cluster > last) + .min() + .map_or(self.text.len(), |cluster| { + cluster.saturating_sub(base) as usize + }); + (first.saturating_sub(base) as usize).min(end)..end + } + + /// Text blob of `glyph..glyph + count` at `positions`, carrying the + /// clusters' source text so PDF output maps vertical alternates back to + /// the original characters. + fn text_blob(&self, glyph: usize, count: usize, positions: &[SkPoint]) -> Option { + let range = self.text_range(glyph, count); + let Some(text) = self + .text + .as_bytes() + .get(range.clone()) + .filter(|t| !t.is_empty()) + else { + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&self.font, count, None); + glyphs.copy_from_slice(&self.glyphs[glyph..glyph + count]); + points.copy_from_slice(positions); + return builder.make(); + }; + let text_start = self.utf8_range.start as u32 + range.start as u32; + let mut builder = TextBlobBuilder::new(); + let (glyphs, points, utf8_text, clusters) = + builder.alloc_run_text_pos(&self.font, count, text.len(), None); + glyphs.copy_from_slice(&self.glyphs[glyph..glyph + count]); + points.copy_from_slice(positions); + utf8_text.copy_from_slice(text); + for (cluster, source) in clusters + .iter_mut() + .zip(&self.clusters[glyph..glyph + count]) + { + *cluster = source.saturating_sub(text_start); + } + builder.make() + } + /// Text blob of one cluster, positioned from its first glyph's pen x. pub(super) fn cluster_blob(&self, glyph: usize, count: usize) -> Option { - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&self.font, count, None); - glyphs.copy_from_slice(&self.glyphs[glyph..glyph + count]); - points.copy_from_slice(&self.cluster_positions(glyph, count)); - builder.make() + self.text_blob(glyph, count, &self.cluster_positions(glyph, count)) } /// Text blob of the whole run at its shaped positions. pub(super) fn blob(&self) -> Option { - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&self.font, self.glyphs.len(), None); - glyphs.copy_from_slice(&self.glyphs); - points.copy_from_slice(&self.positions); - builder.make() + self.text_blob(0, self.glyphs.len(), &self.positions) + } + + /// Text blob of one glyph at its pen origin. The first glyph of a + /// cluster carries the cluster's source text; the rest are glyph-only. + pub(super) fn glyph_blob(&self, glyph: usize) -> Option { + self.glyphs.get(glyph)?; + let starts_cluster = glyph == 0 || self.clusters.get(glyph) != self.clusters.get(glyph - 1); + if starts_cluster { + self.text_blob(glyph, 1, &[SkPoint::default()]) + } else { + single_glyph_blob(&self.font, self.glyphs[glyph]) + } } /// Local-y shift that centres a cluster's ink after a 90° rotation. @@ -217,6 +274,7 @@ impl RunHandler for RunCollector { clusters: self.scratch_clusters.clone(), advance: info.advance.x, utf8_range: info.utf8_range.clone(), + text: String::new(), }); } @@ -279,6 +337,12 @@ pub(super) fn shape_segment( f32::MAX, &mut collector, ); + for run in &mut collector.runs { + run.text = text + .get(run.utf8_range.clone()) + .unwrap_or_default() + .to_string(); + } collector.runs }