mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 07:39:23 +00:00
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:
1 parent
0073927bf4
commit
d2d5a400da
16 files changed
+802
-70
No files matched your search
@@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Vendored
+25
@@ -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
|
||||
|
||||
Vendored
+6
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
Vendored
+8
-8
@@ -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
@@ -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
@@ -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(), ¶ms);
|
||||
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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+49
-2
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user