mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-08 19:42:12 +00:00
From PdfCraft 0b670caf4: - P7 (PdfCraft #3109, #3116): text in mode 7 (clip only) installs its clip and reaches the device as GlyphDrawMode::Clip, which the hayro renderer does not paint, so extraction that leaves out invisible OCR text keeps a heading drawn through its clip. Test: clipping_only_text_reaches_the_device_apart_from_invisible_text in crates/pdf/tests/upstream_text.rs. - (49) (PdfCraft #2949 and 09668ce88): a TrueType post table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped is accepted, and a swapped 2.0 table's glyph names are read. Unit test on a synthetic sfnt in font::blob::tests. - (50) (PdfCraft #3958): the CFF glyph-name map is built once on first use and shared between clones instead of scanning the charset per code. PdfCraft's unit test comes with it.
514 lines
25 KiB
Rust
514 lines
25 KiB
Rust
//! Observable resource, object-depth and text-paint compatibility contracts.
|
|
#![cfg(feature = "read")]
|
|
#![allow(clippy::unwrap_used, clippy::panic)]
|
|
|
|
use craft_pdf::syntax::object::{FromBytes, ObjRef, Object};
|
|
|
|
fn stream(dictionary: &str, content: &[u8]) -> Vec<u8> {
|
|
let mut bytes = format!("<< {dictionary} /Length {} >>\nstream\n", content.len()).into_bytes();
|
|
bytes.extend_from_slice(content);
|
|
bytes.extend_from_slice(b"\nendstream");
|
|
bytes
|
|
}
|
|
|
|
fn object(bytes: &mut Vec<u8>, number: usize, body: &[u8]) -> usize {
|
|
let offset = bytes.len();
|
|
bytes.extend_from_slice(format!("{number} 0 obj\n").as_bytes());
|
|
bytes.extend_from_slice(body);
|
|
bytes.extend_from_slice(b"\nendobj\n");
|
|
offset
|
|
}
|
|
|
|
fn assemble(objects: &[Vec<u8>]) -> Vec<u8> {
|
|
let mut bytes = b"%PDF-1.7\n".to_vec();
|
|
let offsets: Vec<_> = objects.iter().enumerate().map(|(i, body)| object(&mut bytes, i + 1, body)).collect();
|
|
let start = bytes.len();
|
|
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes());
|
|
for offset in offsets {
|
|
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
|
}
|
|
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objects.len() + 1).as_bytes());
|
|
bytes
|
|
}
|
|
|
|
fn nested_object(depth: usize, kind: usize) -> Vec<u8> {
|
|
let mut value = "42".to_string();
|
|
for level in 0..depth {
|
|
value = if kind == 0 || kind == 2 && level % 2 == 0 { format!("[{value}]") } else { format!("<< /V {value} >>") };
|
|
}
|
|
value.into_bytes()
|
|
}
|
|
|
|
fn assert_nested_leaf(mut value: Object<'_>, depth: usize) {
|
|
for _ in 0..depth {
|
|
value = match value {
|
|
Object::Array(array) => array.iter::<Object<'_>>().next().unwrap(),
|
|
Object::Dict(dict) => dict.get::<Object<'_>>(b"V").unwrap(),
|
|
_ => panic!("expected another direct container"),
|
|
};
|
|
}
|
|
assert_eq!(value.into_i32(), Some(42));
|
|
}
|
|
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn direct_objects_accept_64_containers_and_reject_65() {
|
|
for kind in 0..3 {
|
|
let accepted = nested_object(64, kind);
|
|
assert_nested_leaf(Object::from_bytes(&accepted).unwrap(), 64);
|
|
assert!(Object::from_bytes(&nested_object(65, kind)).is_none(), "container kind {kind}");
|
|
}
|
|
}
|
|
|
|
// Object 6 exists only inside an ASCIIHex-decoded object stream. A real xref stream
|
|
// selects the compressed object, exercising its parser rather than a direct-byte helper.
|
|
fn compressed_object(value: &[u8]) -> Vec<u8> {
|
|
let mut payload = b"6 0 ".to_vec();
|
|
payload.extend_from_slice(value);
|
|
let encoded = payload.iter().map(|b| format!("{b:02X}")).collect::<String>() + ">";
|
|
let objects = [
|
|
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
|
b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>".to_vec(),
|
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R >>".to_vec(),
|
|
stream("", b""),
|
|
stream("/Type /ObjStm /N 1 /First 4 /Filter /ASCIIHexDecode", encoded.as_bytes()),
|
|
];
|
|
let mut bytes = b"%PDF-1.7\n".to_vec();
|
|
let mut offsets = vec![0];
|
|
for (i, body) in objects.iter().enumerate() {
|
|
offsets.push(object(&mut bytes, i + 1, body));
|
|
}
|
|
let xref = bytes.len();
|
|
let mut rows = Vec::new();
|
|
for number in 0..8 {
|
|
let (kind, first, second): (u8, u32, u16) = match number {
|
|
0 => (0, 0, 65535),
|
|
6 => (2, 5, 0),
|
|
7 => (1, u32::try_from(xref).unwrap(), 0),
|
|
n => (1, u32::try_from(offsets[n]).unwrap(), 0),
|
|
};
|
|
rows.push(kind);
|
|
rows.extend_from_slice(&first.to_be_bytes());
|
|
rows.extend_from_slice(&second.to_be_bytes());
|
|
}
|
|
object(&mut bytes, 7, &stream("/Type /XRef /Size 8 /Root 1 0 R /W [1 4 2]", &rows));
|
|
bytes.extend_from_slice(format!("startxref\n{xref}\n%%EOF\n").as_bytes());
|
|
bytes
|
|
}
|
|
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn decoded_object_streams_keep_the_same_64_container_boundary() {
|
|
for kind in 0..3 {
|
|
let accepted = craft_pdf::open_slice(&compressed_object(&nested_object(64, kind)), None).unwrap();
|
|
assert_nested_leaf(accepted.xref().get::<Object<'_>>(ObjRef::new(6, 0).into()).unwrap(), 64);
|
|
let rejected = craft_pdf::open_slice(&compressed_object(&nested_object(65, kind)), None).unwrap();
|
|
assert!(rejected.xref().get::<Object<'_>>(ObjRef::new(6, 0).into()).is_none(), "container kind {kind}");
|
|
}
|
|
}
|
|
|
|
mod page_trees {
|
|
use super::assemble;
|
|
use craft_pdf::syntax::object::{Array, FromBytes, MaybeRef, Name, Number};
|
|
use craft_pdf::syntax::page::{Page, Rotation};
|
|
|
|
fn assert_page(page: &Page<'_>, id: i32, media: [f64; 4], crop: [f64; 4], rotation: i32, user_unit: f32) {
|
|
assert_eq!(page.raw().get::<i32>(b"ContractPage"), Some(id));
|
|
let actual = page.media_box();
|
|
assert_eq!([actual.x0, actual.y0, actual.x1, actual.y1], media);
|
|
let actual = page.crop_box();
|
|
assert_eq!([actual.x0, actual.y0, actual.x1, actual.y1], crop);
|
|
let actual = match page.rotation() {
|
|
Rotation::None => 0,
|
|
Rotation::Horizontal => 90,
|
|
Rotation::Flipped => 180,
|
|
Rotation::FlippedHorizontal => 270,
|
|
};
|
|
assert_eq!(actual, rotation);
|
|
assert_eq!(page.user_unit(), user_unit);
|
|
}
|
|
|
|
fn deep_page_tree_contract() {
|
|
const DEPTH: usize = 1024;
|
|
let mut objects = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
|
|
for level in 0..DEPTH {
|
|
let number = level + 2;
|
|
let parent = if level == 0 { String::new() } else { format!("/Parent {} 0 R", number - 1) };
|
|
let attributes = match level {
|
|
0 => "/MediaBox [0 0 200 300] /CropBox [1 2 190 290] /Rotate 90 /Resources << /Font << /Root << /BaseFont /Helvetica >> >> >>",
|
|
512 => "/MediaBox [0 0 300 400] /Rotate 180 /Resources << /Font << /Middle << /BaseFont /Courier >> >> >>",
|
|
_ => "",
|
|
};
|
|
objects.push(format!("<< /Type /Pages /Count 1 /Kids [{} 0 R] {parent} {attributes} >>", number + 1).into_bytes());
|
|
}
|
|
// An empty page is valid. Omitting Contents also prevents a brute-force scan
|
|
// from satisfying this contract after losing the actual page-tree ancestry.
|
|
objects.push(format!("<< /Type /Page /Parent {} 0 R /ContractPage 1024 /UserUnit 2 >>", DEPTH + 1).into_bytes());
|
|
let pdf = craft_pdf::open_slice(&assemble(&objects), None).unwrap();
|
|
assert_eq!(pdf.pages().len(), 1, "the valid leaf must survive all 1024 ancestors");
|
|
let page = &pdf.pages()[0];
|
|
assert_page(page, 1024, [0.0, 0.0, 300.0, 400.0], [1.0, 2.0, 190.0, 290.0], 180, 2.0);
|
|
let middle = Name::from_bytes(b"/Middle").unwrap();
|
|
let root = Name::from_bytes(b"/Root").unwrap();
|
|
let font = page.resources().get_font(&middle).unwrap();
|
|
assert_eq!(font.get::<Name<'_>>(b"BaseFont").unwrap().as_str(), "Courier");
|
|
assert!(page.resources().get_font(&root).is_none(), "the middle dictionary replaces the root scope");
|
|
}
|
|
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn deep_page_trees_preserve_leaf_attributes_and_resources() {
|
|
// Assert the observable contract before the small-stack check, so an old
|
|
// recursive backend fails on missing pages before it can overflow that stack.
|
|
deep_page_tree_contract();
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
std::thread::Builder::new().stack_size(128 * 1024).spawn(deep_page_tree_contract).unwrap().join().unwrap();
|
|
}
|
|
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn cyclic_page_trees_keep_valid_sibling_order_and_deduplicate_leaves() {
|
|
let pdf = craft_pdf::open_slice(
|
|
&assemble(&[
|
|
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
|
b"<< /Type /Pages /Count 3 /Kids [3 0 R 6 0 R 5 0 R 4 0 R 6 0 R] /MediaBox [0 0 100 200] /CropBox [1 2 90 180] /Rotate 90 /Resources << /Font << /Root << /BaseFont /Helvetica >> >> >> >>".to_vec(),
|
|
b"<< /Type /Pages /Parent 2 0 R /Count 2 /Kids [2 0 R 5 0 R 4 0 R 5 0 R] /MediaBox [0 0 300 400] /CropBox [10 20 280 370] /Rotate 180 /Resources << /Font << /Local << /BaseFont /Courier >> >> >> >>".to_vec(),
|
|
b"<< /Type /Page /Parent 3 0 R /ContractPage 4 /Rotate 270 /UserUnit 2 /Resources << >> >>".to_vec(),
|
|
b"<< /Type /Page /Parent 3 0 R /ContractPage 5 >>".to_vec(),
|
|
b"<< /Type /Page /Parent 2 0 R /ContractPage 6 >>".to_vec(),
|
|
]),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(pdf.pages().len(), 3, "cycles and repeated references must not hide or duplicate valid pages");
|
|
assert_page(&pdf.pages()[0], 5, [0.0, 0.0, 300.0, 400.0], [10.0, 20.0, 280.0, 370.0], 180, 1.0);
|
|
assert_page(&pdf.pages()[1], 4, [0.0, 0.0, 300.0, 400.0], [10.0, 20.0, 280.0, 370.0], 270, 2.0);
|
|
assert_page(&pdf.pages()[2], 6, [0.0, 0.0, 100.0, 200.0], [1.0, 2.0, 90.0, 180.0], 90, 1.0);
|
|
let root = Name::from_bytes(b"/Root").unwrap();
|
|
let local = Name::from_bytes(b"/Local").unwrap();
|
|
assert!(pdf.pages()[0].resources().get_font(&local).is_some());
|
|
assert!(pdf.pages()[0].resources().get_font(&root).is_none());
|
|
assert!(pdf.pages()[1].resources().get_font(&local).is_none());
|
|
assert!(pdf.pages()[1].resources().get_font(&root).is_none());
|
|
assert!(pdf.pages()[2].resources().get_font(&root).is_some());
|
|
assert!(pdf.pages()[2].resources().get_font(&local).is_none());
|
|
}
|
|
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn sign_only_numeric_children_preserve_later_valid_pages() {
|
|
// Exercise the parser invariant directly: the old raw iterator panicked
|
|
// after array validation accepted '+' but parsing could not read it.
|
|
let array = Array::from_bytes(b"[+ 3]").unwrap();
|
|
let values: Vec<_> = array
|
|
.raw_iter()
|
|
.map(|value| match value {
|
|
MaybeRef::NotRef(value) => value.into_i32(),
|
|
MaybeRef::Ref(_) => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(values, [Some(0), Some(3)]);
|
|
for token in ["+", "+.", "-"] {
|
|
let pdf = craft_pdf::open_slice(
|
|
&assemble(&[
|
|
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
|
format!("<< /Type /Pages /Count 1 /Kids [{token} 3 0 R] /MediaBox [0 0 100 200] /CropBox [1 2 90 180] /Rotate 90 >>")
|
|
.into_bytes(),
|
|
b"<< /Type /Page /Parent 2 0 R /ContractPage 3 >>".to_vec(),
|
|
]),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(pdf.pages().len(), 1, "{token}: an ignored numeric child must not hide the valid sibling");
|
|
assert_page(&pdf.pages()[0], 3, [0.0, 0.0, 100.0, 200.0], [1.0, 2.0, 90.0, 180.0], 90, 1.0);
|
|
let bytes = format!("{token} ");
|
|
assert_eq!(Number::from_bytes(bytes.as_bytes()).unwrap().as_f64(), 0.0, "{token}: keep the reader's zero tolerance");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "interpret")]
|
|
mod painting {
|
|
use super::{assemble, stream};
|
|
use craft_pdf::interpret::{
|
|
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
|
|
TransformExt, font::Glyph,
|
|
};
|
|
use kurbo::{Affine, BezPath, Point, Rect};
|
|
|
|
#[cfg(craft_pdf_unicode_fixtures)]
|
|
const INTER: &[u8] = include_bytes!(concat!(env!("CRAFT_PDF_TEST_FONT_DIR"), "/Inter-Regular.ttf"));
|
|
|
|
#[derive(Debug)]
|
|
struct GlyphEvent {
|
|
text: String,
|
|
mode: char,
|
|
rgba: [f32; 4],
|
|
origin: Point,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Events {
|
|
paths: Vec<[f32; 4]>,
|
|
glyphs: Vec<GlyphEvent>,
|
|
clips: Vec<String>,
|
|
}
|
|
|
|
fn rgba(paint: &Paint<'_>) -> [f32; 4] {
|
|
let Paint::Color(color) = paint else { panic!("solid paint expected") };
|
|
color.to_rgba().components()
|
|
}
|
|
|
|
impl Device<'_> for Events {
|
|
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
|
|
fn set_blend_mode(&mut self, _: BlendMode) {}
|
|
fn draw_path(&mut self, _: &BezPath, _: Affine, paint: &Paint<'_>, _: &PathDrawMode) {
|
|
self.paths.push(rgba(paint));
|
|
}
|
|
fn push_clip_path(&mut self, clip: &ClipPath) {
|
|
self.clips.push(clip.path.to_svg());
|
|
}
|
|
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
|
|
fn draw_glyph(&mut self, glyph: &Glyph<'_>, _: Affine, glyph_transform: Affine, paint: &Paint<'_>, mode: &GlyphDrawMode) {
|
|
let Glyph::Outline(outline) = glyph else { panic!("embedded outline glyph expected") };
|
|
assert!(outline.font_data().unwrap().data.as_ref().as_ref() == fixture_font(), "fallback font cannot satisfy the contract");
|
|
let text = match glyph.as_unicode().unwrap() {
|
|
hayro_cmap::BfString::Char(c) => c.to_string(),
|
|
hayro_cmap::BfString::String(s) => s,
|
|
};
|
|
let mode = match mode {
|
|
GlyphDrawMode::Fill => 'F',
|
|
GlyphDrawMode::Stroke(_) => 'S',
|
|
GlyphDrawMode::Invisible => 'I',
|
|
GlyphDrawMode::Clip => 'C',
|
|
};
|
|
self.glyphs.push(GlyphEvent { text, mode, rgba: rgba(paint), origin: glyph_transform * Point::ORIGIN });
|
|
}
|
|
fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
|
|
fn pop_clip_path(&mut self) {}
|
|
fn pop_transparency_group(&mut self) {}
|
|
}
|
|
|
|
fn events(bytes: &[u8]) -> Events {
|
|
let pdf = craft_pdf::open_slice(bytes, 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, 100.0, 60.0),
|
|
&cache,
|
|
page.xref(),
|
|
InterpreterSettings::default(),
|
|
);
|
|
let mut device = Events::default();
|
|
craft_pdf::interpret_page(page, &mut context, &mut device).unwrap();
|
|
device
|
|
}
|
|
|
|
fn page(resources: &str, content: &[u8], extra: &[Vec<u8>]) -> Vec<u8> {
|
|
let mut objects = vec![
|
|
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
|
b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>".to_vec(),
|
|
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources {resources} >>").into_bytes(),
|
|
stream("", content),
|
|
];
|
|
objects.extend_from_slice(extra);
|
|
assemble(&objects)
|
|
}
|
|
|
|
fn painted_form(color: &str, side: u32) -> Vec<u8> {
|
|
stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 {side} {side}]"), format!("{color} rg 0 0 {side} {side} re f").as_bytes())
|
|
}
|
|
|
|
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
|
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
|
|
const GREEN_SCOPE: &str = "/Resources << /XObject << /Target 7 0 R >> >>";
|
|
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn nearest_page_resource_dictionary_replaces_the_whole_ancestor_dictionary() {
|
|
for (middle, leaf, expected) in [
|
|
("", "", vec![RED]),
|
|
("/Resources << >>", "", vec![]),
|
|
("/Resources << /ProcSet [/PDF] >>", "", vec![]),
|
|
(GREEN_SCOPE, "", vec![GREEN]),
|
|
(GREEN_SCOPE, "/Resources << >>", vec![]),
|
|
("", "/Resources << /XObject << /Unused 7 0 R >> >>", vec![]),
|
|
] {
|
|
let bytes = assemble(&[
|
|
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
|
b"<< /Type /Pages /Count 1 /Kids [3 0 R] /Resources << /XObject << /Target 6 0 R >> >> >>".to_vec(),
|
|
format!("<< /Type /Pages /Parent 2 0 R /Count 1 /Kids [4 0 R] {middle} >>").into_bytes(),
|
|
format!("<< /Type /Page /Parent 3 0 R /MediaBox [0 0 40 40] /Contents 5 0 R {leaf} >>").into_bytes(),
|
|
stream("", b"/Target Do"),
|
|
painted_form("1 0 0", 40),
|
|
painted_form("0 1 0", 40),
|
|
]);
|
|
assert_eq!(events(&bytes).paths, expected, "middle={middle}, page={leaf}");
|
|
}
|
|
}
|
|
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn forms_inherit_only_when_resources_are_absent_and_restore_the_caller_scope() {
|
|
for (scope, first) in [("", Some(RED)), ("/Resources << >>", None), ("/Resources << /ProcSet [/PDF] >>", None), (GREEN_SCOPE, Some(GREEN))] {
|
|
let bytes = page(
|
|
"<< /XObject << /Outer 5 0 R /Target 6 0 R >> >>",
|
|
b"/Outer Do /Target Do",
|
|
&[
|
|
stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 40 40] {scope}"), b"/Target Do"),
|
|
painted_form("1 0 0", 40),
|
|
painted_form("0 1 0", 40),
|
|
],
|
|
);
|
|
let expected: Vec<_> = first.into_iter().chain([RED]).collect();
|
|
assert_eq!(events(&bytes).paths, expected, "{scope}");
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "raster")]
|
|
fn pixels(bytes: &[u8]) -> Vec<u8> {
|
|
use craft_pdf::raster::{self, Options, RenderCache};
|
|
let pdf = craft_pdf::open_slice(bytes, None).unwrap();
|
|
let image = raster::render(&pdf.pages()[0], &RenderCache::new(), &InterpreterSettings::default(), &Options::default()).unwrap();
|
|
assert!(image.diagnostics.is_empty(), "{:?}", image.diagnostics);
|
|
image.bytes().to_vec()
|
|
}
|
|
|
|
#[cfg(feature = "raster")]
|
|
fn center(bytes: &[u8]) -> [u8; 4] {
|
|
pixels(bytes)[(20 * 40 + 20) * 4..(20 * 40 + 20) * 4 + 4].try_into().unwrap()
|
|
}
|
|
|
|
#[cfg(feature = "raster")]
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn type3_procedure_and_font_resources_replace_the_enclosing_scope() {
|
|
for (font_scope, procedure_scope, expected) in [
|
|
("", "", [255, 0, 0, 255]),
|
|
("/Resources << >>", "", [0; 4]),
|
|
(GREEN_SCOPE, "", [0, 255, 0, 255]),
|
|
(GREEN_SCOPE, "/Resources << >>", [0; 4]),
|
|
(GREEN_SCOPE, "/Resources << /ProcSet [/PDF] >>", [0; 4]),
|
|
("/Resources << >>", GREEN_SCOPE, [0, 255, 0, 255]),
|
|
] {
|
|
let bytes = page(
|
|
"<< /Font << /F1 5 0 R >> /XObject << /Target 6 0 R >> >>",
|
|
b"BT /F1 20 Tf 10 10 Td (A) Tj ET",
|
|
&[
|
|
format!("<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1] /Encoding << /Differences [65 /a] >> /CharProcs << /a 8 0 R >> {font_scope} >>").into_bytes(),
|
|
painted_form("1 0 0", 1),
|
|
painted_form("0 1 0", 1),
|
|
stream(procedure_scope, b"1 0 d0 /Target Do"),
|
|
],
|
|
);
|
|
assert_eq!(center(&bytes), expected, "font={font_scope}, procedure={procedure_scope}");
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "raster")]
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn tiling_patterns_do_not_import_missing_resources_from_the_page() {
|
|
for (scope, expected) in
|
|
[("", [0; 4]), ("/Resources << >>", [0; 4]), ("/Resources << /ProcSet [/PDF] >>", [0; 4]), (GREEN_SCOPE, [0, 255, 0, 255])]
|
|
{
|
|
let bytes = page(
|
|
"<< /Pattern << /P 5 0 R >> /XObject << /Target 6 0 R >> >>",
|
|
b"/Pattern cs /P scn 0 0 40 40 re f",
|
|
&[
|
|
stream(
|
|
&format!("/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 40 40] /XStep 40 /YStep 40 {scope}"),
|
|
b"/Target Do",
|
|
),
|
|
painted_form("1 0 0", 40),
|
|
painted_form("0 1 0", 40),
|
|
],
|
|
);
|
|
assert_eq!(center(&bytes), expected, "{scope}");
|
|
}
|
|
}
|
|
|
|
fn fixture_font() -> &'static [u8] {
|
|
#[cfg(craft_pdf_unicode_fixtures)]
|
|
{
|
|
INTER
|
|
}
|
|
#[cfg(not(craft_pdf_unicode_fixtures))]
|
|
{
|
|
panic!("enable the hash-verified fonts.json fixtures")
|
|
}
|
|
}
|
|
|
|
fn text_pdf(content: &str) -> Vec<u8> {
|
|
assemble(&[
|
|
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
|
b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>".to_vec(),
|
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /ExtGState << /G << /ca 0.25 /CA 0.75 >> >> >> >>".to_vec(),
|
|
stream("", content.as_bytes()),
|
|
b"<< /Type /Font /Subtype /TrueType /BaseFont /Inter-Regular /Encoding /WinAnsiEncoding /FirstChar 65 /LastChar 67 /Widths [600 600 600] /FontDescriptor 6 0 R >>".to_vec(),
|
|
b"<< /Type /FontDescriptor /FontName /Inter-Regular /Flags 32 /FontFile2 7 0 R >>".to_vec(),
|
|
stream("", fixture_font()),
|
|
])
|
|
}
|
|
|
|
#[cfg_attr(not(craft_pdf_unicode_fixtures), ignore = "requires hash-verified external fonts and craft_pdf_unicode_fixtures cfg")]
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn filled_stroked_tj_runs_preserve_paint_order_alpha_and_text_advances() {
|
|
let content = |mode| format!("/G gs 1 0 0 rg 0 0 1 RG 2 w BT /F1 20 Tf {mode} Tr 1 0 0 1 10 20 Tm [(A) 400 (B)] TJ (C) Tj ET");
|
|
for mode in [2, 6] {
|
|
let result = events(&text_pdf(&content(mode)));
|
|
let expected = [('F', "A", 10.0), ('F', "B", 14.0), ('S', "A", 10.0), ('S', "B", 14.0), ('F', "C", 26.0), ('S', "C", 26.0)];
|
|
assert_eq!(result.glyphs.len(), expected.len());
|
|
for (glyph, (paint, text, x)) in result.glyphs.iter().zip(expected) {
|
|
assert_eq!((glyph.mode, glyph.text.as_str()), (paint, text), "{mode} Tr: {:?}", result.glyphs);
|
|
assert!((glyph.origin.x - x).abs() < 1e-6 && (glyph.origin.y - 20.0).abs() < 1e-6, "{glyph:?}");
|
|
assert_eq!(glyph.rgba, if paint == 'F' { [1.0, 0.0, 0.0, 64.0 / 255.0] } else { [0.0, 0.0, 1.0, 191.0 / 255.0] });
|
|
}
|
|
if mode == 6 {
|
|
assert_eq!(result.clips, events(&text_pdf(&content(4))).clips, "stroking must not duplicate text clipping outlines");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "raster")]
|
|
#[cfg_attr(not(craft_pdf_unicode_fixtures), ignore = "requires hash-verified external fonts and craft_pdf_unicode_fixtures cfg")]
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn overlapping_translucent_glyphs_fill_the_whole_run_before_stroking() {
|
|
let reference = pixels(&text_pdf(
|
|
"/G gs 1 0 0 rg 0 0 1 RG 2 w BT /F1 20 Tf 0 Tr 1 0 0 1 10 20 Tm [(A) 400 (B)] TJ 1 Tr 1 0 0 1 10 20 Tm [(A) 400 (B)] TJ ET",
|
|
));
|
|
assert!(reference.as_chunks::<4>().0.iter().any(|p| p[0] > 0 && p[2] > 0 && p[3] < 255));
|
|
for mode in [2, 6] {
|
|
let actual = pixels(&text_pdf(&format!("/G gs 1 0 0 rg 0 0 1 RG 2 w BT /F1 20 Tf {mode} Tr 1 0 0 1 10 20 Tm [(A) 400 (B)] TJ ET")));
|
|
assert!(actual == reference, "{mode} Tr must match explicit whole-run fill followed by whole-run stroke");
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "raster")]
|
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
|
fn soft_mask_transfer_endpoints_use_rounded_output_bytes() {
|
|
for (low, high, left, right) in [("0", "0", 0, 0), ("0.5", "0.5", 128, 128), ("1", "1", 255, 255), ("0.5001", "0.5", 128, 128)] {
|
|
let bytes = page(
|
|
"<< /ExtGState << /G 5 0 R >> >>",
|
|
b"/G gs 0 0 1 rg 0 0 40 40 re f",
|
|
&[
|
|
b"<< /SMask << /S /Luminosity /G 6 0 R /TR 7 0 R >> >>".to_vec(),
|
|
stream(
|
|
"/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency /CS /DeviceGray >>",
|
|
b"0 g 0 0 20 40 re f 1 g 20 0 20 40 re f",
|
|
),
|
|
format!("<< /FunctionType 2 /Domain [0 1] /C0 [{low}] /C1 [{high}] /N 1 >>").into_bytes(),
|
|
],
|
|
);
|
|
let rendered = pixels(&bytes);
|
|
for (x, alpha) in [(10, left), (30, right)] {
|
|
assert_eq!(&rendered[(20 * 40 + x) * 4..(20 * 40 + x) * 4 + 4], &[0, 0, alpha, alpha], "C0={low}, C1={high}, x={x}");
|
|
}
|
|
}
|
|
}
|
|
}
|