Merge #6314: fix(render): a TrueType font marked with an out-of-range loca format draws its letters instead of boxes (hayro-interpret patch 83)

# Conflicts:
#	vendor/README.md
This commit is contained in:
bflatastic
2026-11-09 08:04:53 +07:00
4 changed files with 192 additions and 1 deletions
+4
View File
@@ -8814,6 +8814,10 @@ mod icc_profile_cache;
// A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156).
mod symbolic_truetype_cmap;
// A TrueType font whose `head.indexToLocFormat` is out of range reads `loca` in the format its
// length gives (vendored hayro-interpret patch 83).
mod truetype_loca_format;
/// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own.
fn render_first_page(pdf: Vec<u8>) -> RenderedPage {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
@@ -0,0 +1,147 @@
//! A TrueType font whose `head.indexToLocFormat` holds neither 0 nor 1 draws through the `loca`
//! format its table is in (vendored hayro-interpret patch 83). The OpenType `head` table allows
//! only 0 (short offsets) and 1 (long); the glyph reader took anything but 1 as short, so a font
//! whose field reads 256 (pdf.js `issue2537r.pdf`) had its long offsets read as short ones, and
//! "LINE UP" drew as boxes. ISO 32000-2 §9.9 has an embedded TrueType program drawn as the
//! font program it is.
//!
//! The font is built here, in memory: two glyphs (`.notdef`, empty, and a filled square) and a
//! (1, 0) `cmap` subtable mapping `A` to the square. No font file is involved.
use super::render_first_page;
fn be16(out: &mut Vec<u8>, n: u16) {
out.extend_from_slice(&n.to_be_bytes());
}
fn be32(out: &mut Vec<u8>, n: u32) {
out.extend_from_slice(&n.to_be_bytes());
}
/// The font, its `loca` offsets long when `long` and followed by `padding` zero bytes, its
/// `head.indexToLocFormat` set to `format`.
fn square_font(long: bool, padding: usize, format: u16) -> Vec<u8> {
// Glyph 1: one contour, the square 0..1000 on both axes (glyph 0 is empty).
let mut glyf = Vec::new();
for n in [1u16, 0, 0, 1000, 1000] {
be16(&mut glyf, n);
}
be16(&mut glyf, 3); // the contour's last point
be16(&mut glyf, 0); // no instructions
glyf.extend([1u8; 4]); // four on-curve points, two-byte coordinates
for dx in [0i16, 1000, 0, -1000] {
glyf.extend_from_slice(&dx.to_be_bytes());
}
for dy in [0i16, 0, 1000, 0] {
glyf.extend_from_slice(&dy.to_be_bytes());
}
let mut loca = Vec::new();
for offset in [0u32, 0, glyf.len() as u32] {
if long { be32(&mut loca, offset) } else { be16(&mut loca, (offset / 2) as u16) }
}
loca.resize(loca.len() + padding, 0);
let mut head = vec![0; 54];
head[0..4].copy_from_slice(&[0, 1, 0, 0]);
head[12..16].copy_from_slice(&0x5f0f_3cf5u32.to_be_bytes());
head[18..20].copy_from_slice(&1000u16.to_be_bytes());
head[40..42].copy_from_slice(&1000i16.to_be_bytes());
head[42..44].copy_from_slice(&1000i16.to_be_bytes());
head[50..52].copy_from_slice(&format.to_be_bytes());
let mut hhea = vec![0; 36];
hhea[0..4].copy_from_slice(&[0, 1, 0, 0]);
hhea[4..6].copy_from_slice(&1000i16.to_be_bytes());
hhea[34..36].copy_from_slice(&2u16.to_be_bytes());
let mut hmtx = Vec::new();
for _ in 0..2 {
be16(&mut hmtx, 1000);
be16(&mut hmtx, 0);
}
let mut maxp = vec![0, 1, 0, 0];
be16(&mut maxp, 2);
maxp.resize(32, 0);
// (1, 0), format 0: `A` → glyph 1 (the square).
let mut mac = Vec::new();
be16(&mut mac, 0);
be16(&mut mac, 262);
be16(&mut mac, 0);
let mut ids = [0u8; 256];
ids[usize::from(b'A')] = 1;
mac.extend(ids);
let mut cmap = Vec::new();
be16(&mut cmap, 0);
be16(&mut cmap, 1);
be16(&mut cmap, 1);
be16(&mut cmap, 0);
be32(&mut cmap, 12);
cmap.extend(mac);
let tables = [(*b"cmap", cmap), (*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea), (*b"hmtx", hmtx), (*b"loca", loca), (*b"maxp", maxp)];
let mut out = vec![0, 1, 0, 0];
be16(&mut out, tables.len() as u16);
for n in [64u16, 2, 48] {
be16(&mut out, n);
}
let mut payload = Vec::new();
let mut offset = 12 + 16 * tables.len();
for (tag, bytes) in tables {
out.extend(tag);
let mut padded = bytes.clone();
padded.resize(bytes.len().div_ceil(4) * 4, 0);
let sum = padded.as_chunks::<4>().0.iter().fold(0u32, |s, b| s.wrapping_add(u32::from_be_bytes(*b)));
be32(&mut out, sum);
be32(&mut out, offset as u32);
be32(&mut out, bytes.len() as u32);
offset += padded.len();
payload.extend(padded);
}
out.extend(payload);
out
}
/// A 40 × 40 pt page that shows `A` at 30 pt from (5, 5) in the font.
fn page_with_font(font: &[u8]) -> Vec<u8> {
let content = b"BT /F1 30 Tf 5 5 Td (A) Tj ET".to_vec();
let objects: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(),
[format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.clone(), b"\nendstream".to_vec()].concat(),
b"<< /Type /Font /Subtype /TrueType /BaseFont /AAAAAA+Sq /FirstChar 65 /LastChar 65 /Widths [1000] /FontDescriptor 6 0 R >>".to_vec(),
b"<< /Type /FontDescriptor /FontName /AAAAAA+Sq /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80 /FontFile2 7 0 R >>".to_vec(),
[format!("<< /Length {} >>\nstream\n", font.len()).into_bytes(), font.to_vec(), b"\nendstream".to_vec()].concat(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend(format!("{} 0 obj\n", i + 1).into_bytes());
pdf.extend(object);
pdf.extend(b"\nendobj\n");
}
let xref = pdf.len();
pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes());
for offset in offsets {
pdf.extend(format!("{offset:010} 00000 n \n").into_bytes());
}
pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes());
pdf
}
#[test]
fn a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs() {
// The square covers (5, 5)–(35, 35) in page space: row 20 from the top is inside it.
let square_drawn = |long: bool, padding: usize, format: u16| {
let page = render_first_page(page_with_font(&square_font(long, padding, format)));
let at = |x: u32, y: u32| page.rgba.get(((y * page.width + x) * 4) as usize).copied().unwrap_or(255);
assert_eq!(at(2, 2), 255, "nothing is drawn outside the square");
at(20, 20) < 128
};
assert!(square_drawn(true, 0, 1), "long offsets, marked long");
assert!(square_drawn(false, 0, 0), "short offsets, marked short");
assert!(square_drawn(true, 0, 0x0100), "long offsets marked 256: the table's length says long");
assert!(square_drawn(false, 0, 0x0100), "short offsets marked 256: the table's length says short");
// Three short offsets are 6 bytes and three long ones 12: 8 bytes is short ones and padding.
assert!(square_drawn(false, 2, 0x0100), "short offsets and padding marked 256: still short");
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+40
View File
@@ -195,8 +195,48 @@ impl Debug for OpenTypeFontBlob {
}
}
/// PdfCraft patch (83): a copy of the font with its `head.indexToLocFormat` set to the format its
/// `loca` table is in, when the field holds neither 0 (short offsets) nor 1 (long), or `None` when
/// it needs no change. The glyph outline reader takes only 1 as long, so a font whose field reads
/// 256 (the value's bytes swapped) had its long `loca` read as short offsets: every glyph came
/// out wrong, and a line of text set in it drew boxes (pdf.js `issue2537r.pdf`). The `loca`
/// table's length decides: room for `numGlyphs + 1` offsets of 4 bytes is long, room for only
/// that many of 2 bytes short (a table may carry padding after its offsets), and a table too
/// short for either is left as it is.
fn with_loca_format_fixed(data: &[u8], index: u32) -> Option<Vec<u8>> {
use skrifa::raw::types::Tag;
const HEAD: Tag = Tag::new(b"head");
const LOCA: Tag = Tag::new(b"loca");
// The `indexToLocFormat` field's offset in `head`.
const FORMAT_AT: usize = 50;
let f = FontRef::from_index(data, index).ok()?;
let format = f.head().ok()?.index_to_loc_format();
if format == 0 || format == 1 {
return None;
}
let offsets = usize::from(f.maxp().ok()?.num_glyphs()).checked_add(1)?;
let loca_len = f.table_data(LOCA)?.len();
let long = if loca_len >= offsets.checked_mul(4)? {
true
} else if loca_len >= offsets.checked_mul(2)? {
false
} else {
return None;
};
let head_at = f.table_directory.table_records().iter().find(|r| r.tag() == HEAD)?.offset() as usize;
let at = head_at.checked_add(FORMAT_AT)?;
let mut fixed = data.to_vec();
fixed.get_mut(at..at.checked_add(2)?)?.copy_from_slice(&i16::from(long).to_be_bytes());
Some(fixed)
}
impl OpenTypeFontBlob {
pub(crate) fn new(data: FontData, index: u32) -> Option<Self> {
// PdfCraft patch (83): see `with_loca_format_fixed`.
let data = match with_loca_format_fixed(data.as_ref().as_ref(), index) {
Some(fixed) => Arc::new(fixed) as FontData,
None => data,
};
// Check first whether the font is valid so we can unwrap in the closure.
let f = FontRef::from_index(data.as_ref().as_ref(), index).ok()?;
// Reject fonts with invalid post table version, fixes pdf.js issue 9462. Not sure if there