diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock index ee162a0029..1142a20b67 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -463,6 +463,7 @@ dependencies = [ "indexmap", "insta", "macros", + "miniz_oxide", "skia-safe", "thiserror", "unicode-segmentation", diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 22bb7e1590..e71f616191 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -26,6 +26,7 @@ gl = "0.14.0" glam = "0.24.2" indexmap = "2.7.1" macros = { path = "macros" } +miniz_oxide = "0.8.0" skia-safe = { version = "0.93.1", default-features = false, features = [ "gl", "svg", diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index b8e204dcb6..d70f353ef4 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -7,6 +7,9 @@ use crate::uuid::Uuid; use super::vector; use super::RenderResources; +mod glyph_text; +mod to_unicode; + /// Renders a shape tree to a PDF document and returns the raw PDF bytes. /// /// This is a dedicated vector-PDF render path that draws directly to a Skia @@ -49,5 +52,8 @@ pub fn render_to_pdf( let document = on_page.end_page(); document.close(); - Ok(pdf_bytes) + // Skia maps ligatures and other substituted glyphs to U+0000; give them + // back their text so it can be copied from the PDF. + let glyph_texts = glyph_text::collect(tree, id); + Ok(to_unicode::fix_to_unicode(pdf_bytes, &glyph_texts)) } diff --git a/render-wasm/src/render/pdf/glyph_text.rs b/render-wasm/src/render/pdf/glyph_text.rs new file mode 100644 index 0000000000..d3623bf87f --- /dev/null +++ b/render-wasm/src/render/pdf/glyph_text.rs @@ -0,0 +1,278 @@ +//! Collects the text behind each drawn glyph, per font. Skia's PDF backend +//! maps substituted glyphs (ligatures, contextual alternates) to U+0000. + +use std::collections::HashMap; + +use skia_safe as skia; + +use crate::shapes::Type; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +#[derive(Debug, Clone)] +struct GlyphText { + text: String, + /// The glyph is the only one drawn for its cluster. + single_glyph: bool, + typeface: skia::typeface::TypefaceId, + /// Two typefaces sharing a font name disagree on this glyph. + conflict: bool, +} + +/// Text for each glyph, keyed by the font name Skia writes in `/BaseFont` +/// (without the subset tag). +#[derive(Debug, Default)] +pub struct GlyphTexts { + fonts: HashMap>, +} + +impl GlyphTexts { + /// Text that should replace the current mapping of `glyph` in `font`, if + /// any. `current` is the mapping Skia wrote (empty or `"\0"` when unknown). + pub fn replacement(&self, font: &str, glyph: skia::GlyphId, current: &str) -> Option<&str> { + let entry = self.fonts.get(font)?.get(&glyph)?; + if entry.conflict || entry.text.is_empty() || entry.text == current { + return None; + } + let unmapped = current.is_empty() || current == "\0"; + // A single glyph standing for several characters is a ligature: the + // font's `cmap` entry (e.g. U+FB02 for "fl") is not what was typed. + let ligature = entry.single_glyph && entry.text.chars().count() > 1; + (unmapped || ligature).then_some(entry.text.as_str()) + } + + pub fn is_empty(&self) -> bool { + self.fonts.is_empty() + } + + #[cfg(test)] + pub(super) fn with_glyph(mut self, font: &str, glyph: skia::GlyphId, text: &str) -> Self { + let value = GlyphText { + text: text.to_string(), + single_glyph: true, + typeface: 1, + conflict: false, + }; + self.insert(font, glyph, value); + self + } + + fn insert(&mut self, font: &str, glyph: skia::GlyphId, value: GlyphText) { + let glyphs = self.fonts.entry(font.to_string()).or_default(); + match glyphs.get_mut(&glyph) { + None => { + glyphs.insert(glyph, value); + } + // Same typeface, different text: keep the first one, as Skia does. + Some(existing) + if existing.typeface != value.typeface && existing.text != value.text => + { + existing.conflict = true; + } + Some(_) => {} + } + } + + /// Records the glyphs of a laid-out paragraph. `text` is the UTF-8 text the + /// paragraph was built from. + pub fn add_paragraph(&mut self, text: &str, paragraph: &mut skia::textlayout::Paragraph) { + let mut runs: Vec = Vec::new(); + paragraph.visit(|_, info| { + let Some(info) = info else { + return; + }; + let typeface = info.font().typeface(); + runs.push(Run { + typeface: typeface.unique_id(), + font_name: pdf_font_name(&typeface), + glyphs: info.glyphs().to_vec(), + starts: info.utf8_starts().iter().map(|s| *s as usize).collect(), + }); + }); + self.add_runs(text, &runs); + } + + fn add_runs(&mut self, text: &str, runs: &[Run]) { + // Cluster starts of the whole paragraph: a cluster ends where the next + // one starts, and that one may sit in another run or line. + let mut boundaries: Vec = runs + .iter() + .flat_map(|run| run.starts.iter().copied()) + .chain([text.len()]) + .collect(); + boundaries.sort_unstable(); + boundaries.dedup(); + + for run in runs { + let count = run.glyphs.len(); + for (i, &glyph) in run.glyphs.iter().enumerate() { + let start = run.starts[i]; + if i > 0 && run.starts[i - 1] == start { + continue; + } + let single_glyph = i + 1 >= count || run.starts[i + 1] != start; + if let Some(cluster) = cluster_text(text, &boundaries, start) { + self.insert( + &run.font_name, + glyph, + GlyphText { + text: cluster.to_string(), + single_glyph, + typeface: run.typeface, + conflict: false, + }, + ); + } + } + } + } +} + +struct Run { + typeface: skia::typeface::TypefaceId, + font_name: String, + glyphs: Vec, + starts: Vec, +} + +/// Text of the cluster starting at `start`. Whitespace ends it: spaces trimmed +/// at a line break are not drawn, so their start is missing from `boundaries`. +fn cluster_text<'a>(text: &'a str, boundaries: &[usize], start: usize) -> Option<&'a str> { + let end = boundaries + .iter() + .copied() + .find(|b| *b > start) + .unwrap_or(text.len()); + let cluster = text.get(start..end)?; + match cluster + .char_indices() + .skip(1) + .find(|(_, c)| c.is_whitespace()) + { + Some((at, _)) => Some(&cluster[..at]), + None => Some(cluster), + } +} + +/// The name Skia's PDF backend writes for a typeface: its PostScript name, or +/// its family name when it has none. +fn pdf_font_name(typeface: &skia::Typeface) -> String { + typeface + .post_script_name() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| typeface.family_name()) +} + +/// Collects the glyph texts of every visible text shape under `root`. +pub fn collect(tree: ShapesPoolRef, root: &Uuid) -> GlyphTexts { + let mut glyph_texts = GlyphTexts::default(); + let Some(root_shape) = tree.get(root) else { + return glyph_texts; + }; + for id in root_shape.all_children_iter(tree, false, true) { + let Some(shape) = tree.get(&id) else { + continue; + }; + let Type::Text(text_content) = &shape.shape_type else { + continue; + }; + if shape.hidden { + continue; + } + let text_content = text_content.new_bounds(shape.selrect()); + let width = text_content.get_width(shape.selrect().width()); + for group in text_content + .paragraph_builder_group_from_text(None) + .iter_mut() + { + for builder in group.iter_mut() { + // `build` takes the text out of the builder. + let text = builder.get_text().to_string(); + let mut paragraph = builder.build(); + paragraph.layout(width); + glyph_texts.add_paragraph(&text, &mut paragraph); + } + } + } + glyph_texts +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(glyphs: &[u16], starts: &[usize]) -> Run { + Run { + typeface: 1, + font_name: "Font".to_string(), + glyphs: glyphs.to_vec(), + starts: starts.to_vec(), + } + } + + #[test] + fn ligature_glyph_maps_to_its_whole_cluster() { + let mut texts = GlyphTexts::default(); + // "office": o, ffi (one glyph), c, e + texts.add_runs("office", &[run(&[10, 20, 30, 40], &[0, 1, 4, 5, 6])]); + + assert_eq!(texts.replacement("Font", 20, "\0"), Some("ffi")); + assert_eq!(texts.replacement("Font", 20, "\u{FB03}"), Some("ffi")); + assert_eq!(texts.replacement("Font", 10, "o"), None); + assert_eq!(texts.replacement("Font", 30, "\0"), Some("c")); + } + + #[test] + fn single_char_glyph_with_cmap_entry_keeps_it() { + let mut texts = GlyphTexts::default(); + texts.add_runs("a", &[run(&[5], &[0, 1])]); + + assert_eq!(texts.replacement("Font", 5, "\u{00E1}"), None); + assert_eq!(texts.replacement("Font", 5, ""), Some("a")); + } + + #[test] + fn multi_glyph_cluster_only_fills_the_first_glyph() { + let mut texts = GlyphTexts::default(); + // "é" drawn as base + mark glyphs sharing one cluster. + texts.add_runs("é", &[run(&[7, 8], &[0, 0, 2])]); + + assert_eq!(texts.replacement("Font", 7, "\0"), Some("é")); + assert_eq!(texts.replacement("Font", 8, "\0"), None); + } + + #[test] + fn clusters_end_at_starts_from_other_runs() { + let mut texts = GlyphTexts::default(); + // Right-to-left run: glyph order is the reverse of text order, and its + // last cluster ends where the next run starts. + texts.add_runs( + "abcd", + &[run(&[3, 2], &[1, 0, 0]), run(&[4, 5], &[2, 3, 4])], + ); + + assert_eq!(texts.replacement("Font", 3, "\0"), Some("b")); + assert_eq!(texts.replacement("Font", 2, "\0"), Some("a")); + } + + #[test] + fn cluster_stops_at_whitespace_dropped_at_line_breaks() { + let mut texts = GlyphTexts::default(); + // "lo world" wraps after "lo": the space at 2 is not drawn, so the + // "o" cluster would run until "w" without the whitespace stop. + texts.add_runs("lo world", &[run(&[8, 9], &[0, 1, 3])]); + + assert_eq!(texts.replacement("Font", 9, "\0"), Some("o")); + } + + #[test] + fn conflicting_typefaces_with_the_same_name_are_ignored() { + let mut texts = GlyphTexts::default(); + texts.add_runs("a", &[run(&[5], &[0, 1])]); + let mut other = run(&[5], &[0, 1]); + other.typeface = 2; + texts.add_runs("b", &[other]); + + assert_eq!(texts.replacement("Font", 5, "\0"), None); + } +} diff --git a/render-wasm/src/render/pdf/to_unicode.rs b/render-wasm/src/render/pdf/to_unicode.rs new file mode 100644 index 0000000000..a724490bc7 --- /dev/null +++ b/render-wasm/src/render/pdf/to_unicode.rs @@ -0,0 +1,793 @@ +//! Rewrites the `/ToUnicode` maps of a Skia PDF's Type0 fonts in an appended +//! incremental update, leaving the bytes Skia wrote untouched. + +use std::collections::{BTreeMap, HashMap}; + +use super::glyph_text::GlyphTexts; + +/// Glyph code to UTF-16 text. +type CMap = BTreeMap>; + +/// Returns `pdf` with fixed `/ToUnicode` maps, or unchanged when nothing needs +/// fixing or the file is not laid out as expected. +pub fn fix_to_unicode(pdf: Vec, glyph_texts: &GlyphTexts) -> Vec { + if glyph_texts.is_empty() { + return pdf; + } + match updated_cmaps(&pdf, glyph_texts) { + Some(Update { + cmaps, + xref_offset, + trailer, + }) if !cmaps.is_empty() => append_update(pdf, &cmaps, xref_offset, &trailer), + _ => pdf, + } +} + +struct Update { + /// ToUnicode object number to its new CMap. + cmaps: BTreeMap, + xref_offset: usize, + trailer: Vec, +} + +fn updated_cmaps(pdf: &[u8], glyph_texts: &GlyphTexts) -> Option { + let xref = parse_xref(pdf)?; + let mut cmaps = BTreeMap::new(); + + for &offset in xref.offsets.values() { + let Some(font) = read_object(pdf, offset) else { + continue; + }; + if dict_name(font.dict, b"/Type") != Some(b"Font".as_slice()) + || dict_name(font.dict, b"/Subtype") != Some(b"Type0".as_slice()) + { + continue; + } + let (Some(base_font), Some(to_unicode)) = ( + dict_name(font.dict, b"/BaseFont"), + dict_ref(font.dict, b"/ToUnicode"), + ) else { + continue; + }; + let font_name = strip_subset_tag(&decode_name(base_font)); + let Some(stream) = xref + .offsets + .get(&to_unicode) + .and_then(|offset| read_object(pdf, *offset)) + .and_then(|object| stream_data(&object)) + else { + continue; + }; + + let mut cmap = parse_cmap(&stream); + let mut changed = false; + for (code, value) in cmap.iter_mut() { + let current = String::from_utf16_lossy(value); + if let Some(text) = glyph_texts.replacement(&font_name, *code, ¤t) { + *value = text.encode_utf16().collect(); + changed = true; + } + } + if changed { + cmaps.insert(to_unicode, cmap); + } + } + + Some(Update { + cmaps, + xref_offset: xref.offset, + trailer: xref.trailer, + }) +} + +fn append_update( + mut pdf: Vec, + cmaps: &BTreeMap, + prev_xref: usize, + trailer: &[u8], +) -> Vec { + if pdf.last() != Some(&b'\n') { + pdf.push(b'\n'); + } + let mut offsets = Vec::with_capacity(cmaps.len()); + for (&number, cmap) in cmaps { + let data = write_cmap(cmap); + offsets.push((number, pdf.len())); + pdf.extend_from_slice( + format!("{number} 0 obj\n<> stream\n", data.len()).as_bytes(), + ); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream\nendobj\n"); + } + + let xref_offset = pdf.len(); + pdf.extend_from_slice(b"xref\n"); + for (number, offset) in offsets { + pdf.extend_from_slice(format!("{number} 1\n{offset:010} 00000 n \n").as_bytes()); + } + // Same entries as the previous trailer (`/Size`, `/Root`, `/Info`, `/ID`), + // plus `/Prev`. + let body = &trailer[..trailer.len() - 2]; + pdf.extend_from_slice(b"trailer\n"); + pdf.extend_from_slice(body); + pdf.extend_from_slice( + format!("\n/Prev {prev_xref}>>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes(), + ); + pdf +} + +// --------------------------------------------------------------------------- +// CMap + +fn parse_cmap(data: &[u8]) -> CMap { + let mut cmap = CMap::new(); + let mut tokens = CMapTokens { data, pos: 0 }; + while let Some(token) = tokens.next() { + match token { + Token::Keyword(b"beginbfchar") => { + while let Some(Token::Hex(code)) = tokens.next() { + let Some(Token::Hex(value)) = tokens.next() else { + break; + }; + if let Some(code) = to_code(&code) { + cmap.insert(code, to_utf16(&value)); + } + } + } + Token::Keyword(b"beginbfrange") => { + while let Some(Token::Hex(start)) = tokens.next() { + let (Some(Token::Hex(end)), Some(first)) = (tokens.next(), tokens.next()) + else { + break; + }; + let (Some(start), Some(end)) = (to_code(&start), to_code(&end)) else { + continue; + }; + match first { + Token::Hex(value) => { + let value = to_utf16(&value); + for (step, code) in (start..=end).enumerate() { + let mut value = value.clone(); + if let Some(last) = value.last_mut() { + *last = last.wrapping_add(step as u16); + } + cmap.insert(code, value); + } + } + Token::ArrayStart => { + let mut code = start; + while let Some(Token::Hex(value)) = tokens.next() { + cmap.insert(code, to_utf16(&value)); + code = code.wrapping_add(1); + } + } + _ => break, + } + } + } + _ => {} + } + } + cmap +} + +fn write_cmap(cmap: &CMap) -> Vec { + let mut out = String::from( + "/CIDInit /ProcSet findresource begin\n\ + 12 dict begin\n\ + begincmap\n\ + /CIDSystemInfo\n\ + << /Registry (Adobe)\n\ + /Ordering (UCS)\n\ + /Supplement 0\n\ + >> def\n\ + /CMapName /Adobe-Identity-UCS def\n\ + /CMapType 2 def\n\ + 1 begincodespacerange\n\ + <0000> \n\ + endcodespacerange\n", + ); + let entries: Vec<_> = cmap.iter().collect(); + // At most 100 entries per section (PDF 32000-1, 9.10.3). + for chunk in entries.chunks(100) { + out.push_str(&format!("{} beginbfchar\n", chunk.len())); + for (code, value) in chunk { + out.push_str(&format!("<{code:04X}> <")); + for unit in value.iter() { + out.push_str(&format!("{unit:04X}")); + } + out.push_str(">\n"); + } + out.push_str("endbfchar\n"); + } + out.push_str( + "endcmap\n\ + CMapName currentdict /CMap defineresource pop\n\ + end\n\ + end", + ); + out.into_bytes() +} + +enum Token<'a> { + Hex(Vec), + ArrayStart, + ArrayEnd, + Keyword(&'a [u8]), + Other, +} + +struct CMapTokens<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Iterator for CMapTokens<'a> { + type Item = Token<'a>; + + fn next(&mut self) -> Option> { + self.pos = skip_ws(self.data, self.pos); + let byte = *self.data.get(self.pos)?; + let start = self.pos; + self.pos += 1; + match byte { + b'<' if self.data.get(self.pos) == Some(&b'<') => { + self.pos += 1; + Some(Token::Other) + } + b'<' => { + let end = find(self.data, b">", self.pos)?; + let hex = parse_hex(&self.data[self.pos..end]); + self.pos = end + 1; + Some(Token::Hex(hex)) + } + b'[' => Some(Token::ArrayStart), + b']' => Some(Token::ArrayEnd), + _ => { + while self + .data + .get(self.pos) + .is_some_and(|b| !is_ws(*b) && !is_delimiter(*b)) + { + self.pos += 1; + } + Some(Token::Keyword(&self.data[start..self.pos])) + } + } + } +} + +fn parse_hex(hex: &[u8]) -> Vec { + let digits: Vec = hex + .iter() + .filter_map(|b| (*b as char).to_digit(16).map(|d| d as u8)) + .collect(); + digits + .chunks(2) + .map(|pair| (pair[0] << 4) | pair.get(1).copied().unwrap_or(0)) + .collect() +} + +fn to_code(bytes: &[u8]) -> Option { + match bytes { + [byte] => Some(*byte as u16), + [high, low] => Some(u16::from_be_bytes([*high, *low])), + _ => None, + } +} + +fn to_utf16(bytes: &[u8]) -> Vec { + bytes + .chunks(2) + .map(|pair| u16::from_be_bytes([pair[0], pair.get(1).copied().unwrap_or(0)])) + .collect() +} + +// --------------------------------------------------------------------------- +// PDF objects + +struct Xref { + /// Byte offset of the `xref` keyword. + offset: usize, + /// Object number to byte offset, for objects in use. + offsets: HashMap, + /// The trailer dictionary, `<<` to `>>`. + trailer: Vec, +} + +/// Reads the last xref table and its trailer. Skia writes a single classic +/// table, without xref streams. +fn parse_xref(pdf: &[u8]) -> Option { + let startxref = rfind(pdf, b"startxref")?; + let (offset, _) = parse_uint(pdf, skip_ws(pdf, startxref + b"startxref".len()))?; + parse_xref_at(pdf, offset as usize) +} + +fn parse_xref_at(pdf: &[u8], offset: usize) -> Option { + if !pdf.get(offset..)?.starts_with(b"xref") { + return None; + } + let mut offsets = HashMap::new(); + let mut pos = offset + b"xref".len(); + loop { + pos = skip_ws(pdf, pos); + if pdf.get(pos..)?.starts_with(b"trailer") { + break; + } + let (first, next) = parse_uint(pdf, pos)?; + let (count, next) = parse_uint(pdf, skip_ws(pdf, next))?; + pos = next; + for number in first..first + count { + let (entry_offset, next) = parse_uint(pdf, skip_ws(pdf, pos))?; + let (_generation, next) = parse_uint(pdf, skip_ws(pdf, next))?; + pos = skip_ws(pdf, next); + if *pdf.get(pos)? == b'n' { + offsets.insert(number as u32, entry_offset as usize); + } + pos += 1; + } + } + let start = skip_ws(pdf, pos + b"trailer".len()); + let end = skip_dict(pdf, start)?; + Some(Xref { + offset, + offsets, + trailer: pdf[start..end].to_vec(), + }) +} + +struct Object<'a> { + dict: &'a [u8], + stream: Option<&'a [u8]>, +} + +/// Reads the dictionary (and stream, if any) of the object at `offset`. +fn read_object(pdf: &[u8], offset: usize) -> Option> { + let (_, pos) = parse_uint(pdf, offset)?; + let (_, pos) = parse_uint(pdf, skip_ws(pdf, pos))?; + let pos = skip_ws(pdf, pos); + if !pdf.get(pos..)?.starts_with(b"obj") { + return None; + } + let start = skip_ws(pdf, pos + b"obj".len()); + let end = skip_dict(pdf, start)?; + let dict = &pdf[start..end]; + + let mut pos = skip_ws(pdf, end); + if !pdf.get(pos..)?.starts_with(b"stream") { + return Some(Object { dict, stream: None }); + } + pos += b"stream".len(); + if pdf.get(pos) == Some(&b'\r') { + pos += 1; + } + if pdf.get(pos) == Some(&b'\n') { + pos += 1; + } + let length = dict_uint(dict, b"/Length")? as usize; + Some(Object { + dict, + stream: Some(pdf.get(pos..pos + length)?), + }) +} + +fn stream_data(object: &Object) -> Option> { + let stream = object.stream?; + match dict_name(object.dict, b"/Filter") { + None => Some(stream.to_vec()), + Some(b"FlateDecode") => miniz_oxide::inflate::decompress_to_vec_zlib(stream).ok(), + Some(_) => None, + } +} + +/// Position right after the `>>` closing the dictionary at `pos`. +fn skip_dict(pdf: &[u8], pos: usize) -> Option { + if !pdf.get(pos..)?.starts_with(b"<<") { + return None; + } + let mut depth = 0; + let mut pos = pos; + while pos < pdf.len() { + match pdf[pos] { + b'<' if pdf.get(pos + 1) == Some(&b'<') => { + depth += 1; + pos += 2; + } + b'>' if pdf.get(pos + 1) == Some(&b'>') => { + depth -= 1; + pos += 2; + if depth == 0 { + return Some(pos); + } + } + b'<' => pos = find(pdf, b">", pos)? + 1, + b'(' => pos = skip_string(pdf, pos)?, + _ => pos += 1, + } + } + None +} + +/// Position right after the literal string starting at `pos`. +fn skip_string(pdf: &[u8], pos: usize) -> Option { + let mut depth = 0; + let mut pos = pos; + while pos < pdf.len() { + match pdf[pos] { + b'\\' => pos += 1, + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + return Some(pos + 1); + } + } + _ => {} + } + pos += 1; + } + None +} + +/// Position of the value for `key`, matching whole keys only. +fn dict_value<'a>(dict: &'a [u8], key: &[u8]) -> Option<&'a [u8]> { + let mut from = 0; + while let Some(at) = find(dict, key, from) { + let end = at + key.len(); + if dict.get(end).is_some_and(|b| is_ws(*b) || is_delimiter(*b)) { + return Some(&dict[skip_ws(dict, end)..]); + } + from = end; + } + None +} + +fn dict_name<'a>(dict: &'a [u8], key: &[u8]) -> Option<&'a [u8]> { + let value = dict_value(dict, key)?.strip_prefix(b"/")?; + let end = value + .iter() + .position(|b| is_ws(*b) || is_delimiter(*b)) + .unwrap_or(value.len()); + Some(&value[..end]) +} + +fn dict_uint(dict: &[u8], key: &[u8]) -> Option { + parse_uint(dict_value(dict, key)?, 0).map(|(value, _)| value) +} + +/// Object number of an indirect reference (`N 0 R`). +fn dict_ref(dict: &[u8], key: &[u8]) -> Option { + let value = dict_value(dict, key)?; + let (number, pos) = parse_uint(value, 0)?; + let (_, pos) = parse_uint(value, skip_ws(value, pos))?; + (value.get(skip_ws(value, pos)) == Some(&b'R')).then_some(number as u32) +} + +/// Decodes `#xx` escapes in a PDF name. +fn decode_name(name: &[u8]) -> String { + let mut out = Vec::with_capacity(name.len()); + let mut i = 0; + while i < name.len() { + if name[i] == b'#' && i + 2 < name.len() { + if let [byte] = parse_hex(&name[i + 1..i + 3])[..] { + out.push(byte); + i += 3; + continue; + } + } + out.push(name[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Drops the `ABCDEF+` subset tag Skia puts before every font name. +fn strip_subset_tag(name: &str) -> String { + let bytes = name.as_bytes(); + if bytes.len() > 7 && bytes[6] == b'+' && bytes[..6].iter().all(u8::is_ascii_uppercase) { + name[7..].to_string() + } else { + name.to_string() + } +} + +fn parse_uint(data: &[u8], pos: usize) -> Option<(u64, usize)> { + let digits = data + .get(pos..)? + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + if digits == 0 { + return None; + } + let value = std::str::from_utf8(&data[pos..pos + digits]) + .ok()? + .parse() + .ok()?; + Some((value, pos + digits)) +} + +fn skip_ws(data: &[u8], mut pos: usize) -> usize { + while data.get(pos).is_some_and(|b| is_ws(*b)) { + pos += 1; + } + pos +} + +fn is_ws(byte: u8) -> bool { + matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | b'\x0c' | b'\0') +} + +fn is_delimiter(byte: u8) -> bool { + matches!( + byte, + b'/' | b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'{' | b'}' | b'%' + ) +} + +fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + hay.get(from..)? + .windows(needle.len()) + .position(|window| window == needle) + .map(|at| at + from) +} + +fn rfind(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()) + .rposition(|window| window == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + use skia_safe as skia; + + use crate::render::RenderResources; + use crate::shapes::{ + Fill, FontFamily, FontStyle, GrowType, Paragraph, SolidColor, TextAlign, TextContent, + TextDirection, TextSpan, Type, + }; + use crate::state::ShapesPool; + use crate::utils::uuid_from_u32_quartet; + use crate::uuid::Uuid; + + fn cmap(entries: &[(u16, &str)]) -> CMap { + entries + .iter() + .map(|(code, text)| (*code, text.encode_utf16().collect())) + .collect() + } + + /// Objects visible in the final file, following `/Prev` back to the + /// first xref table. + fn latest_offsets(pdf: &[u8]) -> HashMap { + let mut xref = parse_xref(pdf).expect("xref"); + let mut offsets = xref.offsets.clone(); + while let Some(prev) = dict_uint(&xref.trailer, b"/Prev") { + xref = parse_xref_at(pdf, prev as usize).expect("previous xref"); + for (number, offset) in &xref.offsets { + offsets.entry(*number).or_insert(*offset); + } + } + offsets + } + + fn object<'a>(pdf: &'a [u8], offsets: &HashMap, number: u32) -> Object<'a> { + read_object(pdf, offsets[&number]).expect("object") + } + + /// The Type0 font's ToUnicode map, as seen by a reader. + fn to_unicode_of_only_font(pdf: &[u8]) -> CMap { + let offsets = latest_offsets(pdf); + let fonts: Vec<_> = offsets + .values() + .filter_map(|offset| read_object(pdf, *offset)) + .filter(|o| dict_name(o.dict, b"/Subtype") == Some(b"Type0".as_slice())) + .collect(); + assert_eq!(fonts.len(), 1, "expected a single Type0 font"); + let number = dict_ref(fonts[0].dict, b"/ToUnicode").expect("ToUnicode ref"); + parse_cmap(&stream_data(&object(pdf, &offsets, number)).expect("cmap data")) + } + + /// Builds a classic PDF from object bodies numbered from 1. + fn build_pdf(objects: &[Vec]) -> Vec { + let mut pdf = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(body); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice( + format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(), + ); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice( + format!( + "trailer\n<>\nstartxref\n{xref}\n%%EOF", + objects.len() + 1 + ) + .as_bytes(), + ); + pdf + } + + fn stream_object(dict: &str, data: &[u8]) -> Vec { + let mut body = format!("<<{dict}/Length {}>> stream\n", data.len()).into_bytes(); + body.extend_from_slice(data); + body.extend_from_slice(b"\nendstream"); + body + } + + #[test] + fn parses_bfchar_and_both_bfrange_forms() { + let data = b"2 beginbfchar\n<0003> <0020>\n<0332> <00660066>\nendbfchar\n\ + 2 beginbfrange\n<0010> <0012> <0041>\n<0020> <0021> [<0061> ]\nendbfrange\n"; + + assert_eq!( + parse_cmap(data), + cmap(&[ + (0x03, " "), + (0x10, "A"), + (0x11, "B"), + (0x12, "C"), + (0x20, "a"), + (0x21, "\u{1F600}"), + (0x0332, "ff"), + ]) + ); + } + + #[test] + fn written_cmap_parses_back() { + let entries: Vec<(u16, &str)> = (0..250).map(|code| (code, "x")).collect(); + let mut original = cmap(&entries); + original.insert(0x0332, "ffi".encode_utf16().collect()); + + let written = write_cmap(&original); + + assert_eq!(parse_cmap(&written), original); + assert!(String::from_utf8(written) + .unwrap() + .contains("100 beginbfchar")); + } + + #[test] + fn appends_fixed_cmap_as_incremental_update() { + let skia_cmap = write_cmap(&cmap(&[(0x03, " "), (0x0332, "\0"), (0x0335, "\u{FB02}")])); + let compressed = miniz_oxide::deflate::compress_to_vec_zlib(&skia_cmap, 6); + let pdf = build_pdf(&[ + b"<>".to_vec(), + b"<>" + .to_vec(), + stream_object("/Filter /FlateDecode", &compressed), + ]); + let texts = GlyphTexts::default() + .with_glyph("My Font", 0x0332, "ffi") + .with_glyph("My Font", 0x0335, "fl") + .with_glyph("My Font", 0x03, " "); + + let fixed = fix_to_unicode(pdf.clone(), &texts); + + assert!( + fixed.starts_with(&pdf), + "original bytes must stay untouched" + ); + assert_eq!( + to_unicode_of_only_font(&fixed), + cmap(&[(0x03, " "), (0x0332, "ffi"), (0x0335, "fl")]) + ); + let trailer = parse_xref(&fixed).unwrap().trailer; + assert_eq!(dict_ref(&trailer, b"/Root"), Some(1)); + assert_eq!(dict_uint(&trailer, b"/Size"), Some(4)); + } + + #[test] + fn leaves_pdf_alone_when_nothing_changes() { + let pdf = build_pdf(&[ + b"<>".to_vec(), + b"<>".to_vec(), + stream_object("", &write_cmap(&cmap(&[(0x03, " ")]))), + ]); + let texts = GlyphTexts::default().with_glyph("Other", 0x0332, "ffi"); + + assert_eq!(fix_to_unicode(pdf.clone(), &texts), pdf); + } + + /// EB Garamond forms standard ligatures (fi, fl, ffi, ...). + const LIGATURE_FONT: &[u8] = + include_bytes!("../../../../frontend/playwright/data/render-wasm/assets/ebgaramond.ttf"); + + fn add_text(pool: &mut ShapesPool, id: Uuid, family: FontFamily, text: &str) { + let mut content = TextContent::new( + skia::Rect::from_xywh(0.0, 0.0, 600.0, 100.0), + GrowType::Fixed, + ); + let span = TextSpan::new( + text.to_string(), + family, + 40.0, + 1.2, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![Fill::Solid(SolidColor(skia::Color::BLACK))], + ); + content.add_paragraph(Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + vec![span], + )); + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 600.0, 100.0); + shape.set_shape_type(Type::Text(content)); + } + + /// The text a PDF reader copies from the page: every glyph code drawn, + /// read through the font's final ToUnicode map. + fn copied_text(pdf: &[u8]) -> String { + let offsets = latest_offsets(pdf); + let page = offsets + .values() + .filter_map(|offset| read_object(pdf, *offset)) + .find(|o| dict_name(o.dict, b"/Type") == Some(b"Page".as_slice())) + .expect("page"); + let contents = dict_ref(page.dict, b"/Contents").expect("contents"); + let content = stream_data(&object(pdf, &offsets, contents)).expect("content"); + let to_unicode = to_unicode_of_only_font(pdf); + CMapTokens { + data: &content, + pos: 0, + } + .filter_map(|token| match token { + Token::Hex(codes) => Some(codes), + _ => None, + }) + .flat_map(|codes| { + codes + .chunks(2) + .filter_map(to_code) + .map(|code| String::from_utf16_lossy(&to_unicode[&code])) + .collect::>() + }) + .collect() + } + + #[test] + fn exported_ligatures_copy_back_as_typed() { + let text = "office fluffy affinity"; + let family = FontFamily::new(uuid_from_u32_quartet(0, 0, 0, 99), 400, FontStyle::Normal); + let id = uuid_from_u32_quartet(0, 0, 0, 1); + let mut pool = ShapesPool::new(); + add_text(&mut pool, id, family, text); + + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + resources + .fonts + .add(family, LIGATURE_FONT, false, false) + .expect("ligature font"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let pdf = super::super::render_to_pdf(&mut resources, &id, &pool, 1.0).expect("pdf"); + + let trailer = parse_xref(&pdf).expect("xref").trailer; + assert!( + dict_uint(&trailer, b"/Prev").is_some(), + "the ligatures should need a ToUnicode fix" + ); + assert_eq!(copied_text(&pdf), text); + } +}