🐛 Fix missing letters when copying text from exported PDFs (#12441)

Skia maps ligatures and other substituted glyphs to U+0000 in the PDF
ToUnicode maps. Rebuild those maps from the laid-out paragraphs and
append them as an incremental update.

Closes #12393
This commit is contained in:
Belén Albeza 2026-10-20 09:32:53 +02:00 committed by GitHub
parent 4635eaa467
commit 60a94c65cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1080 additions and 1 deletions

View File

@ -463,6 +463,7 @@ dependencies = [
"indexmap",
"insta",
"macros",
"miniz_oxide",
"skia-safe",
"thiserror",
"unicode-segmentation",

View File

@ -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",

View File

@ -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))
}

View File

@ -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<String, HashMap<skia::GlyphId, GlyphText>>,
}
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<Run> = 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<usize> = 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<skia::GlyphId>,
starts: Vec<usize>,
}
/// 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);
}
}

View File

@ -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<u16, Vec<u16>>;
/// 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<u8>, glyph_texts: &GlyphTexts) -> Vec<u8> {
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<u32, CMap>,
xref_offset: usize,
trailer: Vec<u8>,
}
fn updated_cmaps(pdf: &[u8], glyph_texts: &GlyphTexts) -> Option<Update> {
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, &current) {
*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<u8>,
cmaps: &BTreeMap<u32, CMap>,
prev_xref: usize,
trailer: &[u8],
) -> Vec<u8> {
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<</Length {}>> 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<u8> {
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> <FFFF>\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<u8>),
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<Token<'a>> {
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<u8> {
let digits: Vec<u8> = 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<u16> {
match bytes {
[byte] => Some(*byte as u16),
[high, low] => Some(u16::from_be_bytes([*high, *low])),
_ => None,
}
}
fn to_utf16(bytes: &[u8]) -> Vec<u16> {
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<u32, usize>,
/// The trailer dictionary, `<<` to `>>`.
trailer: Vec<u8>,
}
/// Reads the last xref table and its trailer. Skia writes a single classic
/// table, without xref streams.
fn parse_xref(pdf: &[u8]) -> Option<Xref> {
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<Xref> {
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<Object<'_>> {
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<Vec<u8>> {
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<usize> {
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<usize> {
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<u64> {
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<u32> {
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<usize> {
hay.get(from..)?
.windows(needle.len())
.position(|window| window == needle)
.map(|at| at + from)
}
fn rfind(hay: &[u8], needle: &[u8]) -> Option<usize> {
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<u32, usize> {
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<u32, usize>, 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<u8>]) -> Vec<u8> {
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<</Size {}\n/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF",
objects.len() + 1
)
.as_bytes(),
);
pdf
}
fn stream_object(dict: &str, data: &[u8]) -> Vec<u8> {
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> <D83DDE00>]\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"<</Type /Catalog>>".to_vec(),
b"<</Type /Font\n/Subtype /Type0\n/BaseFont /AAAAAA+My#20Font\n/ToUnicode 3 0 R>>"
.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"<</Type /Catalog>>".to_vec(),
b"<</Type /Font /Subtype /Type0 /BaseFont /AAAAAA+Font /ToUnicode 3 0 R>>".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::<Vec<_>>()
})
.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);
}
}