mirror of
https://github.com/penpot/penpot.git
synced 2026-10-28 21:40:34 +00:00
🐛 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:
parent
4635eaa467
commit
60a94c65cc
1
render-wasm/Cargo.lock
generated
1
render-wasm/Cargo.lock
generated
@ -463,6 +463,7 @@ dependencies = [
|
||||
"indexmap",
|
||||
"insta",
|
||||
"macros",
|
||||
"miniz_oxide",
|
||||
"skia-safe",
|
||||
"thiserror",
|
||||
"unicode-segmentation",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
278
render-wasm/src/render/pdf/glyph_text.rs
Normal file
278
render-wasm/src/render/pdf/glyph_text.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
793
render-wasm/src/render/pdf/to_unicode.rs
Normal file
793
render-wasm/src/render/pdf/to_unicode.rs
Normal 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, ¤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<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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user