mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
180 lines
7.7 KiB
Rust
180 lines
7.7 KiB
Rust
//! Current-main import contracts across the shared reader's older Device API.
|
|
|
|
use vectorcraft_color::Paint;
|
|
use vectorcraft_doc::{Document, NodeKind};
|
|
|
|
use crate::{ImportOptions, TextAs, import_with_report};
|
|
|
|
const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
|
|
|
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 assemble(objects: &[Vec<u8>]) -> Vec<u8> {
|
|
let mut bytes = b"%PDF-1.7\n".to_vec();
|
|
let mut offsets = vec![];
|
|
for (i, body) in objects.iter().enumerate() {
|
|
offsets.push(bytes.len());
|
|
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
|
bytes.extend_from_slice(body);
|
|
bytes.extend_from_slice(b"\nendobj\n");
|
|
}
|
|
let start = bytes.len();
|
|
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.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 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 100 60] /Contents 4 0 R /Resources << {resources} >> >>").into_bytes(),
|
|
stream("", content),
|
|
];
|
|
objects.extend_from_slice(extra);
|
|
assemble(&objects)
|
|
}
|
|
|
|
pub(crate) fn resource_scope_cases() -> Vec<(&'static str, Vec<u8>, bool)> {
|
|
[
|
|
("absent", "", true),
|
|
("empty", "/Resources << >>", false),
|
|
("unrelated", "/Resources << /ProcSet [/PDF] >>", false),
|
|
("own-off", "/Resources << /ExtGState << /On << /OP false /op false /OPM 1 >> >> >>", false),
|
|
("own-on", "/Resources << /ExtGState << /On << /OP true /op true /OPM 1 >> >> >>", true),
|
|
]
|
|
.into_iter()
|
|
.map(|(name, scope, first)| {
|
|
let form = stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 60] {scope}"), b"/On gs 10 10 20 20 re f");
|
|
let bytes =
|
|
page("/XObject << /Outer 5 0 R >> /ExtGState << /On << /OP true /op true /OPM 1 >> >>", b"/Outer Do /On gs 50 10 20 20 re f", &[form]);
|
|
(name, bytes, first)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn resource_scope_contract() {
|
|
for (name, bytes, first) in resource_scope_cases() {
|
|
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
|
|
let mut overprints = vec![];
|
|
report.document.walk(|node| {
|
|
if matches!(node.kind, NodeKind::Path { clipping: false, .. }) {
|
|
overprints.extend(node.appearance.fill().map(|fill| fill.overprint));
|
|
}
|
|
});
|
|
assert_eq!(overprints, [first, true], "form scope {name}; the caller's resources return after the form");
|
|
assert!(report.warnings.is_empty(), "{name}: {:?}", report.warnings);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn form_resource_scopes_keep_scanned_overprint_in_step_with_interpretation() {
|
|
resource_scope_contract();
|
|
}
|
|
|
|
pub(crate) fn text_pdf(content: &str) -> Vec<u8> {
|
|
page(
|
|
"/Font << /F1 5 0 R >> /ExtGState << /G << /ca 0.25 /CA 0.75 >> >>",
|
|
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("", INTER),
|
|
],
|
|
)
|
|
}
|
|
|
|
pub(crate) fn text_content(mode: u8, explicit: bool, following: bool) -> String {
|
|
let show = "1 0 0 1 10 20 Tm [(A) 400 (B)] TJ";
|
|
let paint = if explicit { format!("0 Tr {show} {} Tr {show}", if mode == 6 { 5 } else { 1 }) } else { format!("{mode} Tr {show}") };
|
|
// The green rectangle also exercises the text clip installed at ET for mode 6.
|
|
let after = if following { " (C) Tj" } else { "" };
|
|
format!("/G gs 1 0 0 rg 0 0 1 RG 2 w BT /F1 20 Tf {paint}{after} ET 0 1 0 rg 0 0 100 60 re f")
|
|
}
|
|
|
|
fn text_document(mode: u8, explicit: bool, text_as: TextAs) -> Document {
|
|
import_with_report(&text_pdf(&text_content(mode, explicit, false)), &ImportOptions { text_as, ..Default::default() }).unwrap().document
|
|
}
|
|
|
|
pub(crate) fn text_order_contract() {
|
|
for mode in [2, 6] {
|
|
for text_as in [TextAs::Text, TextAs::Outlines] {
|
|
let combined = text_document(mode, false, text_as);
|
|
let explicit = text_document(mode, true, text_as);
|
|
assert_eq!(
|
|
serde_json::to_value(&combined).unwrap(),
|
|
serde_json::to_value(&explicit).unwrap(),
|
|
"{mode} Tr, {text_as:?}: imported objects must match a complete fill pass followed by a complete stroke pass"
|
|
);
|
|
}
|
|
}
|
|
let live = text_document(2, false, TextAs::Text);
|
|
let mut texts = vec![];
|
|
live.walk(|node| {
|
|
if let NodeKind::Text(text) = &node.kind {
|
|
texts.push((node.opacity, text.plain_text(), text.first_style().clone()));
|
|
}
|
|
});
|
|
assert_eq!(texts.len(), 2, "the filled and stroked AB runs retain separate alpha: {texts:?}");
|
|
assert_eq!((texts[0].0, texts[0].1.as_str()), (64.0 / 255.0, "AB"));
|
|
assert_eq!((texts[1].0, texts[1].1.as_str()), (191.0 / 255.0, "AB"));
|
|
assert!(texts[0].2.stroke.is_none() && !texts[0].2.fill.is_none());
|
|
assert!(texts[1].2.fill.is_none() && !texts[1].2.stroke.is_none());
|
|
|
|
let outlined = text_document(2, false, TextAs::Outlines);
|
|
let mut paints = vec![];
|
|
outlined.walk(|node| {
|
|
if matches!(node.kind, NodeKind::Path { clipping: false, .. }) {
|
|
if let Some(fill) = node.appearance.fill() {
|
|
paints.push(('F', fill.opacity, fill.paint.clone()));
|
|
}
|
|
if let Some(stroke) = node.appearance.stroke() {
|
|
paints.push(('S', stroke.opacity, stroke.paint.clone()));
|
|
}
|
|
}
|
|
});
|
|
assert_eq!(paints.len(), 3, "one whole-run fill, one whole-run stroke, then the green rectangle");
|
|
assert_eq!((paints[0].0, paints[0].1), ('F', 64.0 / 255.0));
|
|
assert_eq!((paints[1].0, paints[1].1), ('S', 191.0 / 255.0));
|
|
assert_eq!(paints[2].0, 'F');
|
|
assert!(matches!(paints[0].2, Paint::Solid { .. }) && matches!(paints[1].2, Paint::Solid { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn filled_stroked_tj_runs_preserve_app_text_outline_alpha_and_clip_grouping() {
|
|
text_order_contract();
|
|
}
|
|
|
|
pub(crate) fn transfer_pdf(low: &str, high: &str) -> Vec<u8> {
|
|
page(
|
|
"/ExtGState << /G << /SMask << /S /Luminosity /G 5 0 R /TR 6 0 R >> >> >>",
|
|
b"/G gs 1 0 0 rg 0 0 100 60 re f",
|
|
&[
|
|
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 60] /Group << /S /Transparency /CS /DeviceRGB >>", b"1 1 1 rg 0 0 50 60 re f"),
|
|
format!("<< /FunctionType 2 /Domain [0 1] /C0 [{low}] /C1 [{high}] /N 1 >>").into_bytes(),
|
|
],
|
|
)
|
|
}
|
|
|
|
pub(crate) fn transfer_contract() {
|
|
for (low, high, expected) in [("0.5001", "0.5", false), ("1", "0", true), ("0", "1", false)] {
|
|
let report = import_with_report(&transfer_pdf(low, high), &ImportOptions::default()).unwrap();
|
|
let mut inverted = vec![];
|
|
report.document.walk(|node| inverted.extend(node.extras().mask.as_ref().map(|mask| mask.invert)));
|
|
assert_eq!(inverted, [expected], "sampled endpoints C0={low}, C1={high}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn soft_mask_inversion_uses_quantized_transfer_endpoints() {
|
|
transfer_contract();
|
|
}
|