pdf: port PdfCraft's reader fixes, part 1 (syntax and text) (#261)

* hayro-syntax: port PdfCraft's syntax fixes (ledger 17, 19, 20, 23, 4-ext, #3565, base_dimensions floor)

From PdfCraft a2ec27b47:

- (17), (19): a CCITT image with /Rows 0, or with fewer /Rows than its
  /Height, decodes to the image height instead of zero or /Rows rows.
- (20): spaces or tabs between the stream keyword and its line break are
  skipped, so the stream data starts after the line break.
- (4) extension: an inline image's EI followed by a delimiter (EI%, EI[)
  ends the image; only a regular character makes it part of a longer token.
- (23): bytes above 0x7F between two EI candidates don't reject the first;
  the operator look-ahead decides.
- #3565: a doubled sign (--280, +-280) is read and skipped as one number.
- Page::base_dimensions keeps a one-point floor after /UserUnit instead of
  one raw unit.

Also a local bound: a CCITT /Columns past u32::MAX is refused instead of
wrapping to a small width that passed ccitt_size_ok.

Tests: content and number unit tests from PdfCraft, a stream unit test, a
synthetic page test for the floor, a CCITT /Columns unit test, and the two CCITT raster tests in
crates/pdf/tests/upstream_raster.rs.

* hayro-interpret: port PdfCraft's text fixes (ledger 30, 44, P2, P6, P8, 26-ext)

From PdfCraft a2ec27b47:

- (30): WinAnsi 0xA0 and 0xAD and MacRoman 0xCA read as an ordinary space
  and hyphen (Annex D.2 gives them those glyph names) unless /Differences
  names nbspace or sfthyphen.
- (44): a /ToUnicode /Identity-H or /Identity-V name reads two-byte codes
  as their Unicode values; controls and lone surrogates give no text.
- P6: glyph names outside the Adobe Glyph List (f_i, hyphen.case,
  uniXXXXXXXX) read by the glyph list specification's full mapping; names
  that only number a glyph (C39, c251) give no text.
- P8: a substitute font whose charmap lacks a spacing character (U+2002 and
  similar) draws its ordinary space instead of .notdef.
- P2: a Type 3 glyph's /FontBBox is transformed to device space before it
  bounds the glyph's clip and shadings, so shaded glyphs draw at every
  position.
- (26) extension: comma-below letters (s, t, k, l, n, r, G with comma) in
  the standard-font stand-ins are the base letter with the font's comma,
  scaled and placed under the baseline.

Tests: crates/pdf/tests/upstream_text.rs for (30), (44) and P6; the Type 3
shading and comma-below raster tests in crates/pdf/tests/upstream_raster.rs.
P8's PdfCraft test needs a Japanese substitute font and is not ported.

* hayro-syntax: refresh SOURCE.json local hashes for Cargo.lock, ascii_85.rs and filter/mod.rs

They changed in #241 and #253 without their local sha256 values.
This commit is contained in:
aiongg authored and GitHub committed 2026-10-23 23:30:56 -04:00
1 parent 0073927bf4
commit d2d5a400da
16 files changed
+802 -70

No files matched your search

+160 -1
View File
@@ -1,4 +1,5 @@
//! Portable regressions adapted from PdfCraft 7437bde378f43856eada40af25b983812e615e8a (MIT OR Apache-2.0).
//! Portable regressions adapted from PdfCraft 7437bde378f43856eada40af25b983812e615e8a and
//! a2ec27b4775f53c40cad3456936d9406611e3a8d (MIT OR Apache-2.0).
#![cfg(feature = "raster")]
use craft_pdf::{
open_slice,
@@ -269,3 +270,161 @@ fn tiff_predictor_packed_rgb_image_renders_every_row() {
assert_eq!(pixel(10), &[255, 0, 0, 255]);
assert_eq!(pixel(30), &[0, 255, 0, 255]);
}
// An 8 x 4 all-black Group 4 stream, hand-encoded from ITU-T T.6: one horizontal-mode row
// (white 0, black 8), three V0 rows, then EOFB.
const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02];
fn fax_page(space: &str, params: &str) -> Pixels {
let content = "q 20 0 0 20 5 5 cm /Im1 Do Q";
let mut pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {params} >> /Length {} >> stream\n",
content.len(),
ALL_BLACK_8X4.len()
)
.into_bytes();
pdf.extend_from_slice(&ALL_BLACK_8X4);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
render(&pdf)
}
fn pixel(page: &Pixels, x: u32, y: u32) -> &[u8] {
let i = ((y * page.width + x) * 4) as usize;
&page.rgba[i..i + 4]
}
/// Scanners and fax services write Group 4 pages with `/Rows 0`, which ISO 32000-2 Table 11
/// defines as "height not predetermined" (the default), not as zero rows.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn group4_fax_with_rows_zero_decodes_to_the_image_height() {
for rows in ["", "/Rows 4", "/Rows 0"] {
let page = fax_page("/DeviceGray", rows);
assert_eq!(pixel(&page, 15, 24), &[0, 0, 0, 255], "{rows:?}: the fax image paints black");
assert_eq!(pixel(&page, 1, 38), &[255, 255, 255, 255], "{rows:?}: outside the image stays white");
}
}
/// PdfCraft #3308: a fax image whose `/Rows` is below its `/Height` is decoded to its height.
/// `/BlackIs1` turns the fixture white, so rows left undecoded show as black. Also through an
/// `/Indexed` color space, the filter's other output path.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn group4_fax_with_rows_below_its_height_decodes_to_the_image_height() {
for (space, rows) in [("/DeviceGray", "/Rows 2"), ("[/Indexed /DeviceRGB 1 <000000FFFFFF>]", "/Rows 2"), ("/DeviceGray", "/Rows 4")] {
let page = fax_page(space, &format!("{rows} /BlackIs1 true"));
for y in [17, 22, 27, 32] {
assert_eq!(pixel(&page, 15, y), &[255, 255, 255, 255], "{space} {rows}: image row at y {y} is decoded");
}
}
}
/// PdfCraft ledger hayro-interpret P2 (#3178): a shaded Type 3 glyph clips to its `/FontBBox` in
/// device space at every position, not to the glyph-space box.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn shaded_type3_glyph_draws_at_multiple_positions() {
let content = "BT /F 30 Tf 1 0 0 1 50 70 Tm (A) Tj 1 0 0 1 220 70 Tm (A) Tj ET";
let glyph = "100 0 d0 0 0 100 100 re W n /S sh";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Resources << /Font << /F 5 0 R >> >> /Contents 4 0 R >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /A 6 0 R >> /Encoding << /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [100] /Resources << /Shading << /S 7 0 R >> >> >> endobj
6 0 obj << /Length {} >> stream
{glyph}
endstream endobj
7 0 obj << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
glyph.len()
);
let page = render(pdf.as_bytes());
for x in [65, 235] {
let px = pixel(&page, x, 65);
assert!(px[0] < 240 && px[2] < 240 && px[1] < 10, "glyph at {x}: {px:?}");
}
}
/// PdfCraft ledger hayro-interpret (26) extension: comma-below letters in non-embedded standard
/// fonts are the letter with the font's comma under it, apart from the letter and inside the
/// descender, whichever name the font gives them (`/scommaaccent`, `/Kcedilla`, `/uni0219`).
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter() {
let pairs = [
("s", "uni0219"),
("t", "uni021B"),
("T", "uni021A"),
("K", "uni0136"),
("k", "kcedilla"),
("G", "uni0122"),
("G", "Gcedilla"),
("R", "uni0156"),
("s", "scommaaccent"),
("S", "Scommaaccent"),
("t", "tcommaaccent"),
("T", "Tcommaaccent"),
("k", "kcommaaccent"),
("K", "Kcommaaccent"),
("l", "lcommaaccent"),
("L", "Lcommaaccent"),
("n", "ncommaaccent"),
("N", "Ncommaaccent"),
("r", "rcommaaccent"),
("R", "Rcommaaccent"),
("G", "Gcommaaccent"),
];
let letters: Vec<&str> = pairs.iter().flat_map(|(base, accented)| [*base, *accented]).collect();
let (size, baseline) = (40u32, 60u32);
for font in ["Helvetica", "Times-Roman", "Courier", "Helvetica-BoldOblique"] {
let mut content = String::new();
for i in 0..letters.len() {
content.push_str(&format!("BT /F1 {size} Tf {} {} Td <{:02X}> Tj ET\n", 10 + 40 * i, 80 - baseline, 1 + i));
}
let differences = letters.iter().map(|n| format!("/{n}")).collect::<Vec<_>>().join(" ");
let pdf = format!(
"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {} 80] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}endstream endobj
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /{font}
/Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [1 {differences}] >> >> endobj
trailer << /Root 1 0 R >>
%%EOF",
20 + 40 * letters.len(),
content.len()
);
let p = render(pdf.as_bytes());
// The rows (y down) of the i-th letter that have dark pixels.
let ink_rows = |i: u32| -> Vec<u32> {
(0..p.height).filter(|&y| ((8 + 40 * i)..(48 + 40 * i).min(p.width)).any(|x| pixel(&p, x, y)[0] < 128)).collect()
};
for (pair, (_, name)) in pairs.iter().enumerate() {
let (base, accented) = (ink_rows(2 * pair as u32), ink_rows(2 * pair as u32 + 1));
let (Some(&base_bottom), Some(&bottom)) = (base.last(), accented.last()) else {
panic!("{font} {name}: draws nothing ({} rows of its base letter)", base.len());
};
assert!(bottom > base_bottom + 2, "{font} {name}: a mark hangs below the letter ({bottom} vs {base_bottom})");
assert!(
(base_bottom + 1..bottom).any(|y| !accented.contains(&y)),
"{font} {name}: the mark is apart from the letter, a comma and not a cedilla"
);
assert!(bottom < baseline + size * 3 / 10, "{font} {name}: the comma stays within the descender ({bottom})");
}
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Text contracts adapted from PdfCraft a2ec27b4775f53c40cad3456936d9406611e3a8d (MIT OR Apache-2.0):
//! Annex D.2 alias codes, glyph names outside the Adobe Glyph List, and `/ToUnicode` identity names.
#![cfg(feature = "interpret")]
#![allow(clippy::unwrap_used, clippy::panic)]
use craft_pdf::interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt,
font::Glyph,
};
use hayro_cmap::BfString;
use kurbo::{Affine, BezPath, Rect};
/// The text of each glyph drawn, `None` where the font gives none.
#[derive(Default)]
struct TextDevice {
glyphs: Vec<Option<String>>,
}
impl Device<'_> for TextDevice {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, glyph: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) {
self.glyphs.push(glyph.as_unicode().map(|text| match text {
BfString::Char(c) => c.to_string(),
BfString::String(s) => s,
}));
}
fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
/// The glyph texts of a one-page PDF whose font `/F1` is object 5; `extra` holds further objects.
fn glyph_texts(font: &str, extra: &str, content: &[u8]) -> Vec<Option<String>> {
let mut pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n",
content.len()
)
.into_bytes();
pdf.extend_from_slice(content);
pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj {font} endobj\n{extra}trailer << /Root 1 0 R >>\n%%EOF\n").as_bytes());
let pdf = craft_pdf::open_slice(&pdf, None).unwrap();
let page = &pdf.pages()[0];
let cache = InterpreterCache::new();
let mut context =
Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 300.0, 300.0), &cache, page.xref(), InterpreterSettings::default());
let mut device = TextDevice::default();
craft_pdf::interpret_page(page, &mut context, &mut device).unwrap();
device.glyphs
}
fn helvetica_texts(encoding: &str, content: &[u8]) -> Vec<String> {
let font = format!("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding {encoding} >>");
glyph_texts(&font, "", content).into_iter().map(|text| text.unwrap_or_default()).collect()
}
/// PdfCraft ledger hayro-interpret (30): WinAnsiEncoding gives 0xAD and 0xA0 the glyph names
/// `hyphen` and `space`, and MacRomanEncoding gives 0xCA `space` (Annex D.2), so they read as the
/// ordinary hyphen and space rather than U+00AD and U+00A0. A `/Differences` entry naming
/// `sfthyphen` or `nbspace` keeps its glyph list value.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space() {
assert_eq!(
helvetica_texts("/WinAnsiEncoding", b"BT /F1 20 Tf 20 150 Td (a\xadb\xa0c) Tj ET"),
["a", "-", "b", " ", "c"],
"WinAnsi 0xAD and 0xA0"
);
assert_eq!(helvetica_texts("/MacRomanEncoding", b"BT /F1 20 Tf 20 150 Td (a\xcab) Tj ET"), ["a", " ", "b"], "MacRoman 0xCA");
assert_eq!(
helvetica_texts(
"<< /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [88 /sfthyphen 89 /nbspace] >>",
b"BT /F1 20 Tf 20 150 Td (aXbYc) Tj ET"
),
["a", "\u{ad}", "b", "\u{a0}", "c"],
"a named glyph keeps its glyph list value"
);
}
/// PdfCraft ledger hayro-interpret P6 (#3169, #3170): ligature (`f_i`) and suffixed
/// (`hyphen.case`) names read by the glyph list specification's full mapping. A name that only
/// numbers the glyph (`C39`, `c251`), a ZapfDingbats name (`a12`) and a name with no meaning
/// left (`foo`) read as nothing.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn glyph_names_outside_the_glyph_list_still_read_as_text() {
let encoding = "<< /Type /Encoding /Differences [30 /f_i /f_l 2 /hyphen.case 39 /C39 97 /c251 174 /C174 \
88 /a12 /foo /uni00660069 61 /C39.sc 233 /C1] >>";
let texts = helvetica_texts(encoding, b"BT /F1 20 Tf 20 150 Td (\x1e\x1f\x02'a\xaeXYZ=\xe9) Tj ET");
assert_eq!(texts, ["fi", "fl", "-", "", "", "", "", "", "fi", "", ""]);
}
/// PdfCraft ledger hayro-interpret P6: the `uni` and `u` forms take uppercase hex digits only.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn glyph_names_with_lowercase_hex_read_as_nothing() {
let encoding = "<< /Type /Encoding /Differences [65 /uni20ac0308 /uni20AC0308 /u1040c.alt /u1040C.alt /f_uni20ac /f_uni20AC] >>";
let texts = helvetica_texts(encoding, b"BT /F1 20 Tf 20 150 Td (ABCDEF) Tj ET");
assert_eq!(texts, ["", "\u{20ac}\u{308}", "", "\u{1040c}", "f", "f\u{20ac}"]);
}
/// PdfCraft ledger hayro-interpret (44): `/ToUnicode /Identity-H` (a name, not a CMap stream)
/// reads two-byte codes as their Unicode values. Controls (a DEL and a C1 control) are not text.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn identity_to_unicode_names_give_text() {
for name in ["Identity-H", "Identity-V"] {
let font = format!(
"<< /Type /Font /Subtype /Type0 /BaseFont /RobotoMono-Regular /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode /{name} >>"
);
let cid = "6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /RobotoMono-Regular \
/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 600 /CIDToGIDMap /Identity >> endobj\n";
let texts = glyph_texts(&font, cid, b"BT /F1 12 Tf 20 200 Td <005A00FC0072006900630068002020AC007F0085> Tj ET");
let texts: Vec<String> = texts.into_iter().map(|text| text.unwrap_or_default()).collect();
assert_eq!(texts, ["Z", "\u{fc}", "r", "i", "c", "h", " ", "\u{20ac}", "", ""], "{name}");
}
}
+25
View File
@@ -70,6 +70,11 @@ Hostile-input bounds (CodeRabbit findings):
overflowing or turning negative.
- A CID font's `/W2` run that starts past CID 65535 is skipped as in `/W`, instead of
overflowing and discarding the whole table.
- CCITT images: `ccitt_size_ok` (inherited PdfCraft ledger item 2) allows at most 2^20
columns and 2^28 pixels (columns × rows) before the decoder allocates. A `/Columns` past
`u32::MAX` is refused rather than wrapped to a small width. `/Rows` follows PdfCraft (17)
and (19): `/Rows 0` or a value below `/Height` decodes to the image height, and decoding
still stops where the data ends.
Reader limits from the [PdfCraft revision a2ec27b4775f53c40cad3456936d9406611e3a8d ledger](https://github.com/storytold/pdfcraft/blob/a2ec27b4775f53c40cad3456936d9406611e3a8d/vendor/README.md),
by its item ids: hayro-interpret (31), its overflow part: the largest 32-bit Type 0 sample is
@@ -80,6 +85,26 @@ object-stream indexes are parsed once with checked offsets, and direct objects n
64 levels, stricter than PdfCraft's 128. Test:
`function::type0::tests::thirty_two_bit_samples_do_not_overflow`.
Rendering fixes from the same [PdfCraft revision a2ec27b4775f53c40cad3456936d9406611e3a8d ledger](https://github.com/storytold/pdfcraft/blob/a2ec27b4775f53c40cad3456936d9406611e3a8d/vendor/README.md),
by its item ids, each marked `PdfCraft patch` in the source: hayro-syntax (17) and (19), CCITT
`/Rows 0` or fewer `/Rows` than `/Height` decode to the image height; (20), spaces or tabs
between `stream` and its line break; the `EI` extension of (4), `EI` followed by a delimiter
ends an inline image; (23), bytes above 0x7F after an `EI` candidate don't reject it; the
unledgered #3565 fix, a doubled sign (`--280`) is one number in both read and skip, beside the
local zero-tolerance sign tokens; and the `base_dimensions` one-point floor after `/UserUnit`.
Their unit tests come with them; the CCITT raster tests are adapted in `tests/upstream_raster.rs`.
Text fixes from the same ledger in hayro-interpret: (30), the Annex D.2 alias codes (WinAnsi
0xA0 and 0xAD, MacRoman 0xCA) read as an ordinary space and hyphen unless `/Differences` names
them; (44), a `/ToUnicode /Identity-H` or `/Identity-V` name reads two-byte codes as Unicode;
(P6), glyph names outside the Adobe Glyph List (`f_i`, `hyphen.case`, `uniXXXXXXXX`) read by
the glyph list specification's full mapping; (P8), a substitute without a spacing character
draws its ordinary space; (P2), a Type 3 glyph's `/FontBBox` is taken to device space before it
bounds the glyph's clip and shadings; and the extension of (26), comma-below letters (ș ț ķ ļ ņ
ŗ Ģ) in the standard-font stand-ins are built from the font's comma. The text contracts are
adapted in `tests/upstream_text.rs` and the Type 3 and comma-below raster tests in
`tests/upstream_raster.rs`. (P8)'s PdfCraft test needs a Japanese substitute font and is not
ported.
This remains the patched 0.7 backend. It retains the existing Bradford Lab conversion,
floating component precision and codec versions, rather than claiming full 0.8
equivalence. The independent color oracles, backend tests and facade contracts cover
+6 -6
View File
@@ -218,7 +218,7 @@
"path": "src/font/cid.rs",
"source_sha256": "c1dbe7fe5b7f7092d97b84063008df54aebf87db4ba1fdb3a179fab9fe6674b8",
"source_git_blob": "b99dcbc116ca1169df3a229eafd05ee5b9204626",
"sha256": "df194fbcaf7811ad1833038b97bcfed51bd11593d1a4ed96125cb2035630c000"
"sha256": "a0f7e140ddea72e08e78ebc996d4136947fd9bb28911b8745bf05d4941fcf320"
},
{
"path": "src/font/generated/glyph_names.rs",
@@ -290,7 +290,7 @@
"path": "src/font/mod.rs",
"source_sha256": "f7a8cfe30893115cc05906cbec747ad368b5645de101c8cbf7bf622f7611973b",
"source_git_blob": "5ccf5c48a6132b7704baff81ece3492a6a7acae2",
"sha256": "f7a8cfe30893115cc05906cbec747ad368b5645de101c8cbf7bf622f7611973b"
"sha256": "0de994e24c9413e2be1b6116ffe5ea44659df05bf61afdb60e14107630854492"
},
{
"path": "src/font/outline.rs",
@@ -302,25 +302,25 @@
"path": "src/font/standard_font.rs",
"source_sha256": "048bcfb0d7d63ced49cef583c954b7507e5f078313caf5cf565adb97ad4bda46",
"source_git_blob": "c73c75dd22c7937585a0646f49bc89514ce054df",
"sha256": "048bcfb0d7d63ced49cef583c954b7507e5f078313caf5cf565adb97ad4bda46"
"sha256": "01241c51fe85d94fa1bc3963e16a45be7c292104b204c37704e636ba2c0b0c5d"
},
{
"path": "src/font/true_type.rs",
"source_sha256": "8e8ffcf8f9749b10262793f1ecaf86ac7c65e6d5608e013d938a03adf210ad84",
"source_git_blob": "40f33590309100f295cb441069c340f9e034b49b",
"sha256": "8e8ffcf8f9749b10262793f1ecaf86ac7c65e6d5608e013d938a03adf210ad84"
"sha256": "a1262fd7efcf358b85b7863c97733a40e5df4295a9d1b54f66b1bada5898f600"
},
{
"path": "src/font/type1.rs",
"source_sha256": "5779b825fa6a5589978ed5b30910b7af0986ace42bac4e3652f7975a479874d4",
"source_git_blob": "32b5e3ddd9a2ca877d760bc5150bd55c3302bb33",
"sha256": "5779b825fa6a5589978ed5b30910b7af0986ace42bac4e3652f7975a479874d4"
"sha256": "a8eabb741ce3991bca11a4f72f42658b33802b8688dda4cc5904e3e4cdae9955"
},
{
"path": "src/font/type3.rs",
"source_sha256": "257f7a2c8780c8e565f46dfd7dd1e3d50bef1e2c44aae5777994addb0ca26d62",
"source_git_blob": "ccfeeb766472f16f15b04710bab711f669b94adf",
"sha256": "5bd99e9a219c4ed437f964b3b81e10381ad4f0bfdee8b2f9be1a6d3940fcd9b4"
"sha256": "1778362c6c4a5cb015e756fd3f36afd7482f20a230dde0206f4339da922eaca4"
},
{
"path": "src/function/mod.rs",
+26 -1
View File
@@ -45,9 +45,16 @@ pub(crate) struct Type0Font {
/// PdfCraft patch: whether the CIDs belong to an Adobe character collection (Japan1, GB1,
/// CNS1, Korea1), whose glyph numbering no substitute other than a CID-keyed CFF font shares.
adobe_collection: bool,
/// PdfCraft patch: `/ToUnicode /Identity-H` (a name, not a CMap stream), which CAD exporters
/// such as WSCAD write for fonts whose two-byte codes are UTF-16 code units. Acrobat shows
/// such text as the code's Unicode value; the identity CMap the name resolves to maps codes
/// to CIDs only and gave no text at all.
identity_to_unicode: bool,
}
impl Type0Font {
/// Reads a Type 0 font from its dictionary: the descendant CID font, the encoding CMap and
/// the code-to-Unicode map (`/ToUnicode`, the `/Identity-H` name, or the UCS2 CMap).
pub(crate) fn new(
dict: &Dict<'_>,
font_resolver: &FontResolverFn,
@@ -117,6 +124,9 @@ impl Type0Font {
let cache_key = dict.cache_key();
let mut to_unicode = read_to_unicode(dict, cmap_resolver);
let identity_to_unicode = dict
.get::<Name<'_>>(TO_UNICODE)
.is_some_and(|n| matches!(n.as_ref(), b"Identity-H" | b"Identity-V"));
let mut to_unicode_is_cid_indexed = false;
// If there is no ToUnicode map, try to get the UCS2 CMap.
@@ -162,6 +172,7 @@ impl Type0Font {
fallback,
to_unicode_is_cid_indexed,
adobe_collection,
identity_to_unicode,
})
}
@@ -264,7 +275,13 @@ impl Type0Font {
{
return Some(glyph);
}
charmap.map(character)
// PdfCraft patch: a substitute may lack a spacing character (#3601; e.g.
// Adobe-Japan1's U+2002). Draw its ordinary space instead of .notdef;
// the PDF's widths and Unicode mapping still determine advance and text.
charmap.map(character).or_else(|| match character {
'\u{00a0}' | '\u{2000}'..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}' => charmap.map(' '),
_ => None,
})
}
FontType::Cff(c) => {
// Map codepoint to glyph name via AFL, and then look it up.
@@ -430,7 +447,15 @@ impl Type0Font {
}
}
/// The text a character code stands for, or `None` when the font gives none (or, for an
/// identity `/ToUnicode` name, when the code is a control or a lone surrogate).
pub(crate) fn char_code_to_unicode(&self, code: u32) -> Option<BfString> {
if self.identity_to_unicode {
// Controls and lone surrogates are not text.
return char::from_u32(code)
.filter(|c| !c.is_control())
.map(BfString::Char);
}
if let Some(to_unicode) = &self.to_unicode {
let key = if self.to_unicode_is_cid_indexed {
self.code_to_cid(code).unwrap_or(0)
+66
View File
@@ -444,6 +444,29 @@ impl Encoding {
Self::BuiltIn => None,
}
}
/// PdfCraft patch: the glyph name an Annex D.2 *alias code* reads as when the glyph is taken
/// as text rather than looked up in a font program; `None` for every other code.
///
/// Annex D.2 gives WinAnsiEncoding codes 0xA0 and 0xAD the glyph names `space` and `hyphen`,
/// the same names it gives 0x20 and 0x2D, and MacRomanEncoding 0xCA `space`. The tables above
/// call them `nbspace` and `sfthyphen`, whose Adobe Glyph List values U+00A0 and U+00AD are
/// *formatting* characters, so extraction hid ink the reader can see: a soft hyphen is
/// invisible, and text that drew one matched no search and no redaction pattern -- one corpus
/// file alone produced 104 of them -- while a non-breaking space did not match the space a
/// reader typed. pdftotext reads both codes as the ordinary character.
///
/// Only the base table is aliased, in the text direction only. A `/Differences` entry naming
/// `sfthyphen` or `nbspace` is a deliberate statement and keeps its glyph list value, which
/// is what pdftotext does too; and glyph *lookup* is untouched, so a font program's own
/// `sfthyphen` program is still found first.
fn text_alias(&self, code: u8) -> Option<&'static str> {
match (self, code) {
(Self::WinAnsi, 0xA0) | (Self::MacRoman, 0xCA) => Some("space"),
(Self::WinAnsi, 0xAD) => Some("hyphen"),
_ => None,
}
}
}
/// The font stretch.
@@ -646,6 +669,49 @@ pub(crate) fn glyph_name_to_unicode(name: &str) -> Option<char> {
})
}
/// PdfCraft patch: the text of a glyph name `glyph_name_to_unicode` doesn't know, by the
/// Adobe Glyph List Specification's full mapping: drop everything from the first `.`
/// (`hyphen.case`, `a.sc`), split the rest on `_` into components (`f_i`, `f_f_l`), and map each
/// one through the glyph list, as `uniXXXX[XXXX…]` (BMP values, four hex digits each) or as
/// `uXXXX` to `uXXXXXX`. A component it can't map, or a `uni` component with any group that
/// isn't a character, adds nothing; `None` when nothing is left.
pub(crate) fn glyph_name_to_text(name: &str) -> Option<BfString> {
let hex = |h: &str| u32::from_str_radix(h, 16).ok().and_then(char::from_u32);
// The specification's digits are uppercase only (`uni20ac` maps to nothing, review on
// #3239).
let upper_hex = |d: &str| d.bytes().all(|b| matches!(b, b'0'..=b'9' | b'A'..=b'F'));
let base = name.split('.').next().unwrap_or_default();
let mut text = String::new();
for part in base.split('_') {
if let Some(s) = glyph_names::get(part) {
text.push_str(s);
} else if let Some(digits) = part
.strip_prefix("uni")
.filter(|d| !d.is_empty() && d.len() % 4 == 0 && upper_hex(d))
{
// All ASCII, so every fourth byte is a char boundary. A group that isn't a
// character (a surrogate) makes the whole component map to nothing.
let chars: Option<String> = (0..digits.len())
.step_by(4)
.map(|at| digits.get(at..at + 4).and_then(hex))
.collect();
text.extend(chars);
} else if let Some(c) = part
.strip_prefix('u')
.filter(|d| (4..=6).contains(&d.len()) && upper_hex(d))
.and_then(hex)
{
text.push(c);
}
}
let mut chars = text.chars();
match (chars.next(), chars.next()) {
(None, _) => None,
(Some(c), None) => Some(BfString::Char(c)),
_ => Some(BfString::String(text)),
}
}
pub(crate) fn unicode_from_name(name: &str) -> Option<char> {
let convert = |input: &str| u32::from_str_radix(input, 16).ok().and_then(char::from_u32);
+81 -6
View File
@@ -397,7 +397,7 @@ impl StandardKind {
})
}
fn code_to_ps_name(&self, code: u8) -> Option<&str> {
pub(crate) fn code_to_ps_name(&self, code: u8) -> Option<&str> {
let bf = self.base_font;
self.encodings
@@ -445,7 +445,8 @@ impl StandardKind {
/// PdfCraft patch: the outline of a glyph from [`Self::composite_glyph`] — the base letter,
/// then the accent centred on it. An accent above a letter taller than `x` is raised by the
/// difference (the stand-in's accents sit on lowercase letters) and, in an italic font,
/// moved along the slant; an ogonek hangs from the letter's right edge.
/// moved along the slant; an ogonek hangs from the letter's right edge; a comma below is
/// the font's comma, smaller, under the baseline.
fn composite_outline(&self, glyph: GlyphId) -> Option<BezPath> {
let code = u8::try_from(glyph.to_u32().checked_sub(COMPOSITE_GLYPH_BASE)?).ok()?;
let (base, accent, place) = accented_parts(self.code_to_ps_name(code)?)?;
@@ -455,13 +456,27 @@ impl StandardKind {
.map(|g| self.base_font_blob.outline_glyph(g))
};
let mut path = outline(base)?;
let accent = outline(accent)?;
let mut accent = outline(accent)?;
if path.elements().is_empty() || accent.elements().is_empty() {
return Some(path);
}
if place == Place::CommaBelow {
accent = Affine::scale(COMMA_BELOW_SCALE) * accent;
}
let (b, a) = (path.bounding_box(), accent.bounding_box());
let (dx, dy) = match place {
Place::Below => (b.center().x - a.center().x, 0.0),
Place::CommaBelow => {
// Its top a little below the baseline (or the letter's overshoot below it), and
// in an italic font moved back along the slant by as much as it is lowered.
let dy = b.y0.min(0.0) - COMMA_BELOW_GAP * a.height() - a.y1;
let slant = if self.base_font.is_italic() {
dy * ITALIC_ACCENT_SLANT
} else {
0.0
};
(b.center().x - a.center().x + slant, dy)
}
Place::Ogonek => (b.x1 - a.x1, 0.0),
Place::Above => {
let x_top = outline("x")
@@ -546,6 +561,14 @@ impl StandardKind {
}
pub(crate) fn char_code_to_unicode(&self, code: u8) -> Option<char> {
// PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see
// `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it
// is, so the alias is applied here, after `/Differences` has had its say.
if !self.encodings.contains_key(&code)
&& let Some(name) = self.encoding.text_alias(code)
{
return glyph_name_to_unicode(name);
}
self.code_to_ps_name(code).and_then(glyph_name_to_unicode)
}
@@ -574,12 +597,21 @@ const COMPOSITE_GLYPH_BASE: u32 = 0x0100_0000;
/// an italic stand-in (tan 15°; the standard italics lean 12° to 16°).
const ITALIC_ACCENT_SLANT: f64 = 0.27;
/// PdfCraft patch: the size of a comma accent against the font's comma. A full-size comma
/// moved under the baseline reaches past the descenders and into the line below.
const COMMA_BELOW_SCALE: f64 = 0.75;
/// PdfCraft patch: the gap between the baseline and a comma accent, as a share of its height.
const COMMA_BELOW_GAP: f64 = 0.15;
/// Where an accent goes on its base letter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Place {
Above,
Below,
Ogonek,
/// The font's comma, smaller, under the letter (the stand-ins have no `commaaccent`).
CommaBelow,
}
/// PdfCraft patch: the stand-ins for the standard fonts (PDFium's Foxit fonts) carry only the
@@ -588,7 +620,11 @@ enum Place {
/// iText 2 writes Turkish text with Times New Roman left unembedded) drew nothing for them. Each of
/// these Latin Extended-A letters is a base letter the stand-ins have plus one of their accents,
/// as Type 1 `seac` composites and the composites of the standard-14 AFM files are built.
/// Letters whose accent isn't a plain one (ď ľ ť and the comma accents) aren't here.
/// The comma-below letters of Romanian (ș ț) and Latvian (ķ ļ ņ ŗ Ģ) are the base letter and the
/// font's comma, since the stand-ins have no `commaaccent` either; without them a form laid out
/// from its XFA template drew Romanian ș as a blank and ț with a cedilla. Letters whose accent
/// isn't a plain one (ď ľ ť, whose caron is an apostrophe beside the letter, and ģ, whose comma
/// is turned and above it) aren't here.
const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[
("Amacron", "A", "macron", Place::Above),
("amacron", "a", "macron", Place::Above),
@@ -620,6 +656,7 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[
("Gbreve", "G", "breve", Place::Above),
("gbreve", "g", "breve", Place::Above),
("Gdotaccent", "G", "dotaccent", Place::Above),
("Gcommaaccent", "G", "comma", Place::CommaBelow),
("gdotaccent", "g", "dotaccent", Place::Above),
("Hcircumflex", "H", "circumflex", Place::Above),
("hcircumflex", "h", "circumflex", Place::Above),
@@ -633,10 +670,16 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[
("iogonek", "i", "ogonek", Place::Ogonek),
("Idotaccent", "I", "dotaccent", Place::Above),
("Jcircumflex", "J", "circumflex", Place::Above),
("Kcommaaccent", "K", "comma", Place::CommaBelow),
("kcommaaccent", "k", "comma", Place::CommaBelow),
("Lacute", "L", "acute", Place::Above),
("lacute", "l", "acute", Place::Above),
("Lcommaaccent", "L", "comma", Place::CommaBelow),
("lcommaaccent", "l", "comma", Place::CommaBelow),
("Nacute", "N", "acute", Place::Above),
("nacute", "n", "acute", Place::Above),
("Ncommaaccent", "N", "comma", Place::CommaBelow),
("ncommaaccent", "n", "comma", Place::CommaBelow),
("Ncaron", "N", "caron", Place::Above),
("ncaron", "n", "caron", Place::Above),
("Omacron", "O", "macron", Place::Above),
@@ -647,6 +690,8 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[
("ohungarumlaut", "o", "hungarumlaut", Place::Above),
("Racute", "R", "acute", Place::Above),
("racute", "r", "acute", Place::Above),
("Rcommaaccent", "R", "comma", Place::CommaBelow),
("rcommaaccent", "r", "comma", Place::CommaBelow),
("Rcaron", "R", "caron", Place::Above),
("rcaron", "r", "caron", Place::Above),
("Sacute", "S", "acute", Place::Above),
@@ -655,8 +700,12 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[
("scircumflex", "s", "circumflex", Place::Above),
("Scedilla", "S", "cedilla", Place::Below),
("scedilla", "s", "cedilla", Place::Below),
("Scommaaccent", "S", "comma", Place::CommaBelow),
("scommaaccent", "s", "comma", Place::CommaBelow),
("Tcedilla", "T", "cedilla", Place::Below),
("tcedilla", "t", "cedilla", Place::Below),
("Tcommaaccent", "T", "comma", Place::CommaBelow),
("tcommaaccent", "t", "comma", Place::CommaBelow),
("Tcaron", "T", "caron", Place::Above),
("Utilde", "U", "tilde", Place::Above),
("utilde", "u", "tilde", Place::Above),
@@ -680,8 +729,28 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[
("zdotaccent", "z", "dotaccent", Place::Above),
];
/// PdfCraft patch: the comma-below letters of [`ACCENTED_LETTERS`] by character. The glyph list
/// names the Latvian ones `Kcedilla`… (their comma is a cedilla in Unicode's names), has no name
/// for Ț ț, and gives `Tcommaaccent` and `tcommaaccent` to Ţ ţ, which keep their cedilla.
const COMMA_BELOW_BY_CHAR: &[(char, &str)] = &[
('\u{0122}', "Gcommaaccent"),
('\u{0136}', "Kcommaaccent"),
('\u{0137}', "kcommaaccent"),
('\u{013B}', "Lcommaaccent"),
('\u{013C}', "lcommaaccent"),
('\u{0145}', "Ncommaaccent"),
('\u{0146}', "ncommaaccent"),
('\u{0156}', "Rcommaaccent"),
('\u{0157}', "rcommaaccent"),
('\u{0218}', "Scommaaccent"),
('\u{0219}', "scommaaccent"),
('\u{021A}', "Tcommaaccent"),
('\u{021B}', "tcommaaccent"),
];
/// PdfCraft patch: the base letter, accent and placement of an accented letter in
/// [`ACCENTED_LETTERS`], by glyph name (`uni011E` and `u011E` forms too).
/// [`ACCENTED_LETTERS`], by glyph name (`uni011E` and `u011E` forms too). A comma-below letter
/// is found by its character ([`COMMA_BELOW_BY_CHAR`]), so `/uni0136` and `/Kcedilla` are Ķ too.
fn accented_parts(name: &str) -> Option<(&'static str, &'static str, Place)> {
let name = normalized_glyph_name(name);
let by_name = |n: &str| {
@@ -690,5 +759,11 @@ fn accented_parts(name: &str) -> Option<(&'static str, &'static str, Place)> {
.find(|(letter, ..)| *letter == n)
.map(|&(_, base, accent, place)| (base, accent, place))
};
by_name(name).or_else(|| by_name(glyph_names::get_reverse(glyph_name_to_unicode(name)?)?))
by_name(name).or_else(|| {
let c = glyph_name_to_unicode(name)?;
match COMMA_BELOW_BY_CHAR.iter().find(|(letter, _)| *letter == c) {
Some((_, comma_below)) => by_name(comma_below),
None => by_name(glyph_names::get_reverse(c)?),
}
})
}
+12 -5
View File
@@ -2,7 +2,7 @@ use crate::font::blob::{CffFontBlob, OpenTypeFontBlob};
use crate::font::generated::{glyph_names, mac_os_roman, mac_roman, standard, win_ansi};
use crate::font::standard_font::StandardKind;
use crate::font::{
Encoding, FallbackFontQuery, FontFlags, glyph_name_to_unicode, read_to_unicode,
Encoding, FallbackFontQuery, FontFlags, glyph_name_to_text, glyph_name_to_unicode, read_to_unicode,
strip_subset_prefix, unicode_from_name,
};
use crate::util::OptionLog;
@@ -190,10 +190,11 @@ impl TrueTypeFont {
}
match &self.kind {
Kind::Embedded(e) => e
.code_to_unicode_name(code as u8)
.and_then(glyph_name_to_unicode)
.map(BfString::Char),
// PdfCraft patch (#3169): a name outside the glyph list reads by the glyph list
// specification's full mapping (`f_i`, `hyphen.case`).
Kind::Embedded(e) => e.code_to_unicode_name(code as u8).and_then(|name| {
glyph_name_to_unicode(name).map(BfString::Char).or_else(|| glyph_name_to_text(name))
}),
Kind::Standard(s) => s.char_code_to_unicode(code as u8).map(BfString::Char),
}
@@ -286,7 +287,13 @@ impl EmbeddedKind {
self.differences
.get(&code)
.map(|s| s.as_str())
// PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see
// `Encoding::text_alias`.
.or_else(|| self.encoding.text_alias(code))
.or_else(|| self.encoding.map_code(code))
// The last resort reads the code as WinAnsi whatever the font said, so it takes the
// same aliasing.
.or_else(|| Encoding::WinAnsi.text_alias(code))
.or_else(|| win_ansi::get(code))
}
+36 -5
View File
@@ -2,7 +2,8 @@ use crate::font::blob::{CffFontBlob, Type1FontBlob};
use crate::font::standard_font::{StandardFont, StandardKind, select_standard_font};
use crate::font::true_type::{Width, read_encoding, read_widths};
use crate::font::{
Encoding, FallbackFontQuery, glyph_name_to_unicode, normalized_glyph_name, read_to_unicode,
Encoding, FallbackFontQuery, glyph_name_to_text, glyph_name_to_unicode, normalized_glyph_name,
read_to_unicode,
};
use crate::{CMapResolverFn, CacheKey, FontResolverFn};
use hayro_cmap::{BfString, CMap};
@@ -117,11 +118,25 @@ impl Type1Font {
}
let code = char_code as u8;
match &self.1 {
Kind::Standard(s) => s.char_code_to_unicode(code).map(BfString::Char),
Kind::Cff(c) => c.char_code_to_unicode(code).map(BfString::Char),
Kind::Type1(t) => t.char_code_to_unicode(code).map(BfString::Char),
let found = match &self.1 {
Kind::Standard(s) => s.char_code_to_unicode(code),
Kind::Cff(c) => c.char_code_to_unicode(code),
Kind::Type1(t) => t.char_code_to_unicode(code),
};
if let Some(c) = found {
return Some(BfString::Char(c));
}
// PdfCraft patch (#3169, #3170): a name outside the glyph list still reads by the
// glyph list specification's full mapping (`f_i`, `hyphen.case`). A name that only
// numbers the glyph (`C39`, `c251`) says nothing about the character and reads as
// nothing: the code's StandardEncoding character would be a guess, wrong wherever the
// font's /Encoding puts the glyph elsewhere (review on #3239).
let name = match &self.1 {
Kind::Standard(s) => s.code_to_ps_name(code),
Kind::Cff(c) => c.code_to_ps_name(code),
Kind::Type1(t) => t.code_to_ps_name(code),
}?;
glyph_name_to_text(name)
}
}
@@ -239,6 +254,14 @@ impl Type1Kind {
}
fn char_code_to_unicode(&self, code: u8) -> Option<char> {
// PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see
// `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it
// is, so the alias is applied here, after `/Differences` has had its say.
if !self.encodings.contains_key(&code)
&& let Some(name) = self.encoding.text_alias(code)
{
return glyph_name_to_unicode(name);
}
self.code_to_ps_name(code).and_then(glyph_name_to_unicode)
}
}
@@ -336,6 +359,14 @@ impl CffKind {
}
fn char_code_to_unicode(&self, code: u8) -> Option<char> {
// PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see
// `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it
// is, so the alias is applied here, after `/Differences` has had its say.
if !self.encodings.contains_key(&code)
&& let Some(name) = self.encoding.text_alias(code)
{
return glyph_name_to_unicode(name);
}
self.code_to_ps_name(code).and_then(glyph_name_to_unicode)
}
}
+5 -1
View File
@@ -108,6 +108,9 @@ impl<'a> Type3<'a> {
.encodings
.get(&code)
.map(|s| s.as_str())
// PdfCraft patch: an Annex D.2 alias code reads as the ordinary character;
// see `Encoding::text_alias`.
.or_else(|| self.encoding.text_alias(code))
.or_else(|| self.encoding.map_code(code));
name.and_then(crate::font::glyph_name_to_unicode)
.or_else(|| (0x20..0x7f).contains(&code).then_some(code as char))
@@ -165,9 +168,10 @@ impl<'a> Type3<'a> {
is_shape_glyph
};
// PdfCraft patch: context clips and shading bounds are in device space; FontBBox is in glyph space.
let mut context = Context::new_with(
state.ctm,
self.font_bbox,
root_transform.transform_rect_bbox(self.font_bbox),
&glyph.cache,
glyph.xref,
glyph.settings.clone(),
+8 -8
View File
@@ -8,7 +8,7 @@
"path": "Cargo.lock",
"source_sha256": "d73da432072780b3d1fc725da68ad174c75f1720d0ed903521f664bfc16c70a8",
"source_git_blob": "ebc07dfff7b53f62a5ff422c19a779ab61c106cc",
"sha256": "d73da432072780b3d1fc725da68ad174c75f1720d0ed903521f664bfc16c70a8"
"sha256": "6106b3f3ad57258826fa72d2eaebfd2f66a37fccdc67507c348e603f71db088d"
},
{
"path": "Cargo.toml",
@@ -68,7 +68,7 @@
"path": "src/content/mod.rs",
"source_sha256": "b0f36badb2688c90691c27a4e5dcdfec7639858081553242744dd7bc39ffc0d0",
"source_git_blob": "8e47324d0b0bd90ed15efe5ff1e9de0cb46e75d9",
"sha256": "b0f36badb2688c90691c27a4e5dcdfec7639858081553242744dd7bc39ffc0d0"
"sha256": "aa0883ba92dd05655bfd8d54195584bab2c0b28111d76ec73c5381a0f23b6181"
},
{
"path": "src/content/ops.rs",
@@ -134,7 +134,7 @@
"path": "src/filter/ascii_85.rs",
"source_sha256": "df32c6783b1c1969f9b6b03de3bf204384dfdba767f7beefdd8baa8590387d24",
"source_git_blob": "5a18cf8352d7d5a1770b4ea3068254187ff4b4f9",
"sha256": "df32c6783b1c1969f9b6b03de3bf204384dfdba767f7beefdd8baa8590387d24"
"sha256": "054c13b992fd2c4a1a2d9881198eadb82d8c8454666d136b3c2dfaea60f7308a"
},
{
"path": "src/filter/ascii_hex.rs",
@@ -146,7 +146,7 @@
"path": "src/filter/ccitt.rs",
"source_sha256": "101e2e7e69ece280a258edcadd29f5ea0442ce6822cf1dd1906dca41a69436bf",
"source_git_blob": "98924f2dbdd942bc6ee64f66749b8d2496d8b45e",
"sha256": "101e2e7e69ece280a258edcadd29f5ea0442ce6822cf1dd1906dca41a69436bf"
"sha256": "b0fef2271417c3009a1de569c9573b05dd464ff76c1a5f7220f634bb5feb44e2"
},
{
"path": "src/filter/dct.rs",
@@ -176,7 +176,7 @@
"path": "src/filter/mod.rs",
"source_sha256": "5e6d62da5b1fd023f85f79bc8ade928b508214e13668f9fc99f3a08bc4d48a15",
"source_git_blob": "22bebf6d3287f1dc893c67b76a139108b28a8188",
"sha256": "8cc6c2572519046bd27adfc597630045e453829a954eaf1747fee7a447e5c5cb"
"sha256": "e33e74ee55dcc850db9eb64811c56b4ffe1932908ab9684dfba07f4277b1eea9"
},
{
"path": "src/filter/run_length.rs",
@@ -260,7 +260,7 @@
"path": "src/object/number.rs",
"source_sha256": "4bc2df257241ccb66450b7176bcab20d780bc9e22469cec588ebee5fac272905",
"source_git_blob": "bd37b19c3053317dabbfa38d3473618631e6b669",
"sha256": "85598a27cf730aa9d2fe6a7094a2b375e67c4fed588de86b47cb8ebf38a702fa"
"sha256": "f5a6a10e80abd84395edc34ceae43887fc6bc8941c8f0e26799cc4754f6b1543"
},
{
"path": "src/object/rect.rs",
@@ -278,7 +278,7 @@
"path": "src/object/stream.rs",
"source_sha256": "151721c7bfe558a6942aba323d3185cf6db5bf726bd905326881c70ef480240f",
"source_git_blob": "833610817b4738304a7376cd43ea079cb352bd8c",
"sha256": "e83d7ecb01a038d5a0ae589d77e877f9521097010e5f88db8f8a333fd161aff1"
"sha256": "05928b3008cd06098220ee7cdfd03ebc6351c205d947491be37c77cd091dc78e"
},
{
"path": "src/object/string.rs",
@@ -296,7 +296,7 @@
"path": "src/page.rs",
"source_sha256": "19d0e7604a3a692956e523128022d07944253b308793598795e09595cb04e79e",
"source_git_blob": "7d63931a324f5faf90bb37e0a90670643567dace",
"sha256": "681681016eda0d1cdbec10d2cb0305c5f439724ec5d0b3be218a18e99b5e72b8"
"sha256": "e39c9888ff5d1a6e6565d1e9fc810b2e4b285c9a014e5b887080a8839c7d5490"
},
{
"path": "src/pdf.rs",
+79 -12
View File
@@ -40,7 +40,7 @@ use crate::object::name::{Name, skip_name_like};
use crate::object::{Array, Null, Number, Object, Stream};
use crate::reader::Reader;
use crate::reader::{Readable, ReaderContext, ReaderExt, Skippable};
use crate::trivia::{Comment, is_white_space_character};
use crate::trivia::{Comment, is_regular_character, is_white_space_character};
use crate::util::find_needle;
use core::array;
use core::fmt::{Debug, Formatter};
@@ -246,11 +246,12 @@ impl<'a> UntypedIter<'a> {
self.reader.read_bytes(pos)?;
if self.reader.peek_bytes(2) == Some(b"EI") {
// If the following character is not a whitespace character, then we are in a ASCII-85 stream.
// PdfCraft patch: if the following character is a regular character,
// then the token is not `EI` (white space and delimiters both end it).
if self
.reader
.peek_bytes(3)
.is_some_and(|b| !is_white_space_character(b[2]))
.is_some_and(|b| is_regular_character(b[2]))
{
self.reader.read_bytes(3)?;
@@ -299,16 +300,21 @@ impl<'a> UntypedIter<'a> {
find_reader.read_bytes(next_pos)?;
if is_ei {
let analyze_data = &tail[..find_reader.offset()];
// PdfCraft patch: a byte above 0x7F between the two "EI"s
// used to reject the candidate ("binary data in-between, so
// we for sure have not reached the end"). When the candidate
// is the real end, those bytes are the content stream that
// follows the image, and a content stream carries bytes above
// 0x7F inside its strings: WinAnsi text such as `(Grüße) Tj`,
// or the two-byte codes of any Type 0 font. A page whose text
// held one, with a capital "EI" in a later word (EINGANG,
// BEI, KLEIN), had no valid candidate left, so the image data
// ran to the end of the stream and nothing after the image
// was drawn or extracted. The operator check below is the
// discriminator, and `MAX_EI_CANDIDATES` already bounds how
// often it runs.
// If there is any binary data in-between, we for sure
// have not reached the end.
if analyze_data.iter().any(|c| !c.is_ascii()) {
self.reader.read_bytes(2)?;
continue 'outer;
}
// Otherwise, the only possibility that we reached an
// The only possibility that we reached an
// "EI", even though the previous one was valid, is
// that it's part of a string in the content
// stream that follows the inline image. Therefore,
@@ -872,6 +878,67 @@ mod tests {
assert_eq!(operators(data), [b"TJ".to_vec(), b"BI".to_vec(), b"q".to_vec(), b"Q".to_vec()]);
}
/// PdfCraft patch: bytes above 0x7F after an `EI` candidate don't reject it.
#[test]
fn an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte() {
// `(Grüße)` in WinAnsi, then a word holding a capital "EI": both are everyday content,
// and the look-ahead used to read the non-ASCII bytes of the string as image data.
let data = b"BI /W 1 /H 1 /BPC 8 /CS /DeviceGray ID \x00 EI BT /F1 12 Tf (Gr\xFC\xDFe) Tj (EINGANG) Tj ET";
assert_eq!(
operators(data),
[
b"BI".to_vec(),
b"BT".to_vec(),
b"Tf".to_vec(),
b"Tj".to_vec(),
b"Tj".to_vec(),
b"ET".to_vec()
]
);
}
/// PdfCraft patch: `EI` followed by a delimiter ends the image.
#[test]
fn inline_image_ei_may_be_followed_by_a_delimiter() {
let mut image = b"BI /W 16 /H 4 /BPC 8 /CS /G ID ".to_vec();
image.extend([0x80; 64]);
image.extend(b"\nEI");
let tail = b" Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET";
let ops = |suffix: &[u8]| {
let mut data = image.clone();
data.extend(suffix);
data.extend(tail);
operators(&data)
};
let control = ops(b"");
assert_eq!(control.len(), 7);
assert_eq!(ops(b"%c\n"), control);
assert_eq!(ops(b"[] 0 d"), [&control[..1], &[b"d".to_vec()], &control[1..]].concat());
}
#[test]
fn inline_image_ei_followed_by_a_regular_character_is_not_the_end() {
let data = b"BI /W 1 /H 1 /BPC 8 /CS /G ID \x80 EIQ \x81 EI Q BT (a) Tj ET";
// `EIQ` is one token and stays part of the image data.
let expected: Vec<Vec<u8>> =
["BI", "Q", "BT", "Tj", "ET"].iter().map(|s| s.as_bytes().to_vec()).collect();
assert_eq!(operators(data), expected);
}
#[test]
fn ascii85_inline_image_data_may_contain_ei_before_a_delimiter() {
for glue in [&b"EI["[..], b"EI/", b"EI%", b"EI(", b"EI<"] {
let mut data = b"BI /W 4 /H 4 /BPC 8 /CS /G /F /A85 ID 9jqo^".to_vec();
data.extend(glue);
data.extend(b"87cURD]i,\"Ebo80~>EI Q BT /F1 12 Tf (AFTER) Tj ET");
let expected: Vec<Vec<u8>> =
["BI", "Q", "BT", "Tf", "Tj", "ET"].iter().map(|s| s.as_bytes().to_vec()).collect();
assert_eq!(operators(&data), expected, "{}", String::from_utf8_lossy(glue));
}
}
#[test]
fn tokenizer_interface_preserves_every_byte_boundary() {
let data = b"% comment\n1 -2.5 /Name /A#20B (a\\(b\\) \\101) <4869> [1 2] << /Key (value) >> [(a) 1] TJ BI /W 1 /H 1 /BPC 8 /CS /DeviceGray ID \x00 EI q Q";
+35 -2
View File
@@ -15,11 +15,25 @@ pub(crate) fn decode(
) -> Option<FilterResult<'static>> {
let k = params.get::<i32>(K).unwrap_or(0);
let rows = params.get::<u32>(ROWS).unwrap_or(image_params.height);
// PdfCraft patch: `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11, and
// the default), not zero rows. Scanners write it on Group 4 pages, which rendered blank.
// PdfCraft patch (19): fewer `/Rows` than the image's `/Height` doesn't cut the image short: a
// plotter driver wrote `/Rows` equal to `/Columns`, and the rows after it were drawn black
// (#3308). The decoder still stops where the data ends.
let rows = params
.get::<u32>(ROWS)
.filter(|&rows| rows != 0)
.map_or(image_params.height, |rows| rows.max(image_params.height));
let end_of_block = params.get::<bool>(END_OF_BLOCK).unwrap_or(true);
// A `/Columns` past `u32::MAX` is refused: truncated, it would wrap to a small width that
// passes `ccitt_size_ok` and decode the image at the wrong width.
let columns = match params.get::<usize>(COLUMNS) {
Some(columns) => u32::try_from(columns).ok()?,
None => 1728,
};
let settings = DecodeSettings {
columns: params.get::<usize>(COLUMNS).unwrap_or(1728) as u32,
columns,
rows,
end_of_block,
end_of_line: params.get::<bool>(END_OF_LINE).unwrap_or(false),
@@ -154,3 +168,22 @@ pub(crate) fn decode(
}),
})
}
#[cfg(test)]
mod tests {
use super::decode;
use crate::object::stream::ImageDecodeParams;
use crate::object::{Dict, FromBytes};
// An 8 x 4 all-black Group 4 stream: one horizontal-mode row, three V0 rows, then EOFB.
const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02];
#[test]
fn columns_past_u32_max_are_refused_not_wrapped() {
let params = ImageDecodeParams { width: 8, height: 4, ..Default::default() };
let decoded = |dict: &[u8]| decode(&ALL_BLACK_8X4, &Dict::from_bytes(dict).unwrap(), &params);
assert!(decoded(b"<< /K -1 /Columns 8 >>").is_some());
// 2^32 + 8 would wrap to 8 columns.
assert!(decoded(b"<< /K -1 /Columns 4294967304 >>").is_none());
}
}
+59 -13
View File
@@ -70,6 +70,13 @@ impl Number {
impl Skippable for Number {
fn skip(r: &mut Reader<'_>, _: bool) -> Option<()> {
let has_sign = r.forward_if(|b| b == b'+' || b == b'-').is_some();
// PdfCraft patch: one more sign, so that skipping a number covers what `read_inner`
// reads. `--280` was already covered by the `b'-'` arm below, but `-+280` and `++280`
// were not, and a skip that fails where a read succeeds puts the reader back where it
// started (#3630 review).
if has_sign {
r.forward_if(|b| b == b'+' || b == b'-');
}
// Some PDFs have weird trailing minuses, so try to accept those as well.
match r.peek_byte()? {
@@ -113,19 +120,25 @@ impl Readable<'_> for Number {
#[inline(always)]
fn read_inner(r: &mut Reader<'_>) -> Option<Number> {
let first = r.peek_byte()?;
let has_sign = matches!(first, b'+' | b'-');
let negative = match first {
b'-' => {
r.forward();
true
}
b'+' => {
r.forward();
false
}
_ => false,
};
// PdfCraft patch: a doubled sign is one number, not two. `--280` left `negative` set with no
// digits read, so it returned 0 and the reader stopped on `-280`, which became a second
// number: a `cm` with it got seven operands, kept the last six, and the matrix
// `0 0 1 0 -280 10` hid everything drawn under it (#3565). `Number::skip` above already
// consumes the whole token, so read and skip disagreed about where the number ended.
//
// One extra sign is absorbed and the value is negative if either sign was a minus, which is
// what the COS lexer does since #3299 (`--280` and `+-280` are both -280). A third sign is
// left to the digit loop's trailing-minus arm, as before.
let mut negative = false;
let mut signs = 0;
while signs < 2
&& let Some(b @ (b'-' | b'+')) = r.peek_byte()
{
r.forward();
negative |= b == b'-';
signs += 1;
}
let has_sign = signs > 0;
// PdfCraft patch: high-precision float mantissa overflow (jsPDF double-precision coordinates).
// Digits that don't fit in `mantissa` are dropped: past the dot they are only precision;
@@ -350,6 +363,39 @@ mod tests {
use crate::reader::Reader;
use crate::reader::ReaderExt;
/// PdfCraft patch: a doubled sign is one number. `--280` returned 0 and left `-280` to be
/// read as a second number, so a `cm` carrying it got seven operands and a degenerate matrix
/// (#3565). The COS lexer reads these the same way since #3299.
#[test]
fn a_doubled_sign_is_one_number() {
for (src, want) in [("--280", -280), ("+-280", -280), ("-+280", -280), ("++280", 280), ("-280", -280), ("+280", 280)] {
let mut r = Reader::new(src.as_bytes());
assert_eq!(r.read_without_context::<i32>().unwrap(), want, "{src}");
assert!(r.at_end(), "{src}: the whole number was read, leaving nothing for a second one");
}
// The fraction too, and a lone sign is still zero.
assert_eq!(Reader::new("--280.5".as_bytes()).read_without_context::<f64>().unwrap(), -280.5);
assert_eq!(Reader::new("-".as_bytes()).read_without_context::<i32>().unwrap(), 0);
// Skipping covers what reading accepts: a skip that fails where a read succeeds puts the
// reader back where it started, so `Object::skip` would leave the number in place
// (#3630 review).
for src in ["--280", "+-280", "-+280", "++280", "-280", "+280", "--280.5"] {
let mut r = Reader::new(src.as_bytes());
assert!(r.skip::<Number>(false).is_some(), "{src}: not skipped");
assert!(r.at_end(), "{src}: only part of the number was skipped");
}
// A doubled sign alone is a zero token, like a single one.
for token in ["--", "+-", "-+", "++"] {
let bytes = format!("{token} 3");
let mut skipped = Reader::new(bytes.as_bytes());
assert!(skipped.skip::<Number>(false).is_some(), "{token}");
let mut read = Reader::new(bytes.as_bytes());
assert_eq!(read.read_without_context::<Number>().unwrap().as_f64(), 0.0, "{token}");
assert_eq!(read.offset(), skipped.offset(), "{token}");
}
}
#[test]
fn tolerant_signed_and_dot_zero_tokens_match_skip() {
for token in ["+", "-", ".", "+.", "-.", "0", "+0", "-0", "0.", "+0.", "-0."] {
+32 -8
View File
@@ -351,14 +351,25 @@ impl FilterResult<'_> {
}
}
/// PdfCraft patch (20): the end-of-line after the `stream` keyword. Microsoft Reporting Services
/// writes `stream \r\n`, a space before the line break, which the specification does not allow.
/// Without this the content stream was taken to start at the space, failed to inflate, and the
/// page rendered blank (bank statements, invoices) while other viewers showed it. Only spaces and
/// tabs are skipped, and only when a line break follows, so stream data is never swallowed.
fn forward_stream_eol(r: &mut Reader<'_>) -> Option<()> {
r.forward_while(|b| b == b' ' || b == b'\t');
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
// Technically not allowed, but no reason to not try it.
.or_else(|| r.forward_tag(b"\r"))
}
fn parse_proper<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option<Stream<'a>> {
let length = dict.get::<u32>(LENGTH)?;
r.skip_white_spaces_and_comments();
r.forward_tag(b"stream")?;
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
.or_else(|| r.forward_tag(b"\r"))?;
forward_stream_eol(r)?;
let data = r.read_bytes(length as usize)?;
r.skip_white_spaces();
r.forward_tag(b"endstream")?;
@@ -374,11 +385,7 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option<Stream<'a>>
// A wrong `/Length` still recovers, because that keyword sits directly after the dict.
r.skip_white_spaces_and_comments();
r.forward_tag(b"stream")?;
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
// Technically not allowed, but no reason to not try it.
.or_else(|| r.forward_tag(b"\r"))?;
forward_stream_eol(r)?;
let tail = r.tail()?;
let endstream_offset = find_needle(tail, b"endstream")?;
@@ -463,4 +470,21 @@ mod tests {
let mut r = Reader::new(data);
assert!(r.read_with_context::<Stream<'_>>(&ReaderContext::dummy()).is_none());
}
/// PdfCraft patch (20): spaces or tabs between `stream` and its line break are skipped.
#[test]
fn stream_keyword_may_be_followed_by_spaces_before_the_line_break() {
for data in [
&b"<< /Length 10 >> stream \r\nabcdefghij\nendstream"[..],
&b"<< /Length 10 >> stream\t \nabcdefghij\nendstream"[..],
&b"<< /Length 999 >> stream \r\nabcdefghij\nendstream"[..],
] {
let mut r = Reader::new(data);
let stream = r
.read_with_context::<Stream<'_>>(&ReaderContext::dummy())
.unwrap();
assert_eq!(stream.data, b"abcdefghij");
}
}
}
+49 -2
View File
@@ -604,9 +604,17 @@ impl<'a> Page<'a> {
{
(A4.width() as f32, A4.height() as f32)
} else {
// PdfCraft patch: retain a one-point floor after UserUnit, not one raw unit.
// The stored unit is finite and within 1..=75000. Round upward only if the
// reciprocal's f32 product would fall below one and trigger a placeholder.
let mut raw_floor = 1.0 / self.user_unit;
if raw_floor * self.user_unit < 1.0 {
raw_floor = raw_floor.next_up();
}
let raw_floor = f64::from(raw_floor);
(
crop_box.width().max(1.0) as f32,
crop_box.height().max(1.0) as f32,
crop_box.width().max(raw_floor) as f32,
crop_box.height().max(raw_floor) as f32,
)
}
}
@@ -1005,6 +1013,45 @@ mod page_tree_tests {
assert_eq!(repaired.get().len(), 1);
assert_eq!(repaired.get()[0].raw().obj_id().unwrap().obj_number, 3);
}
fn raw_crop_unit_page(unit: f64, crop: [f64; 4]) -> Vec<u8> {
let bounds = crop.map(|n| n.to_string()).join(" ");
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [{bounds}] /CropBox [{bounds}] /UserUnit {unit} /Resources << >> /Contents [] >>"),
];
pdf_bytes(&objects)
}
/// PdfCraft patch: a tiny crop box keeps a one-point floor after `/UserUnit`, not one raw unit.
#[test]
fn raw_crop_unit_floor_is_one_point_after_user_unit() {
for (unit, width, height) in [(1.0, 1.0, 1.0), (4096.0, 512.0, 1024.0), (75000.0, 9375.0, 18750.0)] {
let pdf = Pdf::new(raw_crop_unit_page(unit, [10.0, 20.0, 10.125, 20.25])).unwrap();
let page = &pdf.pages()[0];
assert_eq!(page.base_dimensions(), ((width / unit) as f32, (height / unit) as f32), "unit={unit}");
}
// In f32, the nearest reciprocal of 41 or 47 multiplies back to 0.99999994.
for unit in [1.0, 41.0, 47.0] {
let pdf = Pdf::new(raw_crop_unit_page(unit, [0.0, 0.0, 0.01, 0.01])).unwrap();
let page = &pdf.pages()[0];
let (width, height) = page.base_dimensions();
assert!(width * page.user_unit() >= 1.0 && height * page.user_unit() >= 1.0, "unit={unit}");
let (width, height) = page.render_dimensions();
assert!((1.0..=1.000001).contains(&width) && (1.0..=1.000001).contains(&height), "unit={unit}: {width} by {height}");
}
// A nearly-zero crop box still falls back to A4.
for unit in [1.0, 4096.0, 75000.0] {
for side in [0.0, 0.0001] {
let pdf = Pdf::new(raw_crop_unit_page(unit, [0.0, 0.0, side, side])).unwrap();
let page = &pdf.pages()[0];
let expected = (595.2755905511812_f64 as f32, 841.8897637795277_f64 as f32);
assert_eq!(page.base_dimensions(), expected, "unit={unit}, side={side}");
assert_eq!(page.render_dimensions(), (expected.0 * unit as f32, expected.1 * unit as f32));
}
}
}
}
#[cfg(test)]