use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;
use base64::Engine;
#[cfg(not(target_arch = "wasm32"))]
use jpeg2k::{ColorSpace as Jpeg2000ColorSpace, Image as Jpeg2000Image};
use lopdf::content::{Content, Operation};
use lopdf::{Dictionary, Document, Object, ObjectId, Stream};
#[cfg(not(target_arch = "wasm32"))]
use rayon::prelude::*;
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::DrawSettings;
use skrifa::raw::TableProvider;
use skrifa::{FontRef, MetadataProvider, Tag};
use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
use crate::error::{Error, Result};
use crate::ir::{
GradientStop, IDENTITY, LineCap, LineJoin, LinearGradient, Matrix, Node, Page, Paint,
RadialGradient, SourceMeta, Stroke, TextAnchor, TextRun, TilingPatternDefinition, compose,
transform_point,
};
use crate::jpeg2000::{jpx_media_type, parse_jpx_header};
use crate::pdf_base14::standard_metrics;
const MAX_GRAPHICS_STACK: usize = 256;
const MAX_FORM_DEPTH: usize = 32;
const MAX_PAGE_MESH_TRIANGLES: usize = 50_000;
const MAX_PDF_ANNOTATIONS_PER_PAGE: usize = 10_000;
const MAX_PDF_PAGE_NODES: usize = 250_000;
const MAX_PDF_CHOICE_OPTIONS: usize = 10_000;
const MAX_PDF_CHOICE_TEXT_BYTES: usize = 2 * 1024 * 1024;
const MAX_PDF_FORM_VALUE_BYTES: usize = 2 * 1024 * 1024;
const MAX_PDF_DEFAULT_APPEARANCE_BYTES: usize = 64 * 1024;
const MAX_PDF_JPX_PIXELS: usize = 20_000_000;
const MAX_PDF_JPX_DECODED_SAMPLE_BYTES: usize = 128 * 1024 * 1024;
const MAX_PDF_JPX_PNG_BYTES: usize = 64 * 1024 * 1024;
#[derive(Clone, Debug)]
struct PdfChoiceOption {
export_value: Vec<u8>,
display_value: Vec<u8>,
}
#[derive(Clone, Copy, Debug)]
struct JpxSampleLayout {
row_bytes: usize,
width: usize,
height: usize,
components: usize,
bits: usize,
}
const UNDECODABLE_IMAGE_FILTERS: &[&[u8]] = &[b"JBIG2Decode"];
pub(crate) fn convert(
path: &Path,
options: &ConvertOptions,
sink: &mut dyn PageConsumer,
) -> Result<Vec<String>> {
let bytes = read_limited_file(path, options.max_input_bytes, "PDF input")?;
convert_bytes(&bytes, options, sink)
}
pub(crate) fn convert_bytes(
bytes: &[u8],
options: &ConvertOptions,
sink: &mut dyn PageConsumer,
) -> Result<Vec<String>> {
if bytes.len() as u64 > options.max_input_bytes {
return Err(Error::LimitExceeded(format!(
"PDF input exceeds maximum limit of {} bytes",
options.max_input_bytes
)));
}
let (document, recovery_warning) =
load_document_bytes(bytes, options.max_zip_entry_bytes, options.max_input_bytes)?;
if document.is_encrypted() {
return Err(Error::Unsupported(
"encrypted PDFs are rejected; access controls are not bypassed".into(),
));
}
let decrypted_in_place = document.was_encrypted();
let pages = document.get_pages().into_iter().collect::<Vec<_>>();
if pages.len() > options.max_pages {
return Err(Error::LimitExceeded(format!(
"PDF contains {} pages; maximum is {}",
pages.len(),
options.max_pages
)));
}
let page_content_limit = usize::try_from(options.max_zip_entry_bytes)
.unwrap_or(usize::MAX)
.min(usize::MAX / 2);
let mut warnings = Vec::new();
warnings.extend(recovery_warning);
#[cfg(not(target_arch = "wasm32"))]
if options.jobs > 1 && pages.len() > 1 {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(options.jobs)
.build()
.map_err(|error| {
Error::InvalidInput(format!("cannot create PDF worker pool: {error}"))
})?;
for batch in pages.chunks(options.jobs.max(1)) {
let rendered = pool.install(|| {
batch
.par_iter()
.map(|(page_number, page_id)| {
render_page(
&document,
*page_number as usize,
*page_id,
page_content_limit,
options.outline_embedded_pdf_text,
decrypted_in_place,
)
})
.collect::<Vec<_>>()
});
for page in rendered {
let page = page?;
warnings.extend(page.warnings.iter().cloned());
sink.consume(page)?;
}
}
return Ok(deduplicate(warnings));
}
for (page_number, page_id) in pages {
let page = render_page(
&document,
page_number as usize,
page_id,
page_content_limit,
options.outline_embedded_pdf_text,
decrypted_in_place,
)?;
warnings.extend(page.warnings.iter().cloned());
sink.consume(page)?;
}
Ok(deduplicate(warnings))
}
fn decode_ascii_hex(input: &[u8]) -> Vec<u8> {
let mut nibbles = Vec::with_capacity(input.len());
for &byte in input {
if byte == b'>' {
break;
}
if let Some(value) = (byte as char).to_digit(16) {
nibbles.push(value as u8);
}
}
if nibbles.len() % 2 == 1 {
nibbles.push(0);
}
nibbles
.chunks_exact(2)
.map(|pair| (pair[0] << 4) | pair[1])
.collect()
}
fn decode_stream_leniently(
stream: &Stream,
remaining: usize,
content_limit: usize,
) -> Result<Vec<u8>> {
match stream.decompressed_content_with_limit(remaining) {
Ok(data) => Ok(data),
Err(lopdf::Error::Decompress(lopdf::DecompressError::MemoryLimitExceeded { .. })) => {
Err(Error::LimitExceeded(format!(
"PDF page content stream exceeds {content_limit} bytes"
)))
}
Err(_) => {
if stream.content.len() > remaining {
return Err(Error::LimitExceeded(format!(
"PDF page content stream exceeds {content_limit} bytes"
)));
}
Ok(stream.content.clone())
}
}
}
fn page_content_with_limit(
document: &Document,
page_id: ObjectId,
content_limit: usize,
) -> Result<Vec<u8>> {
let mut content = Vec::new();
for object_id in document.get_page_contents(page_id) {
let Ok(stream) = document.get_object(object_id).and_then(Object::as_stream) else {
continue;
};
let remaining = content_limit.saturating_sub(content.len());
let filters = stream.filters().unwrap_or_default();
let data = if filters.first() == Some(&b"ASCIIHexDecode".as_slice()) {
let decoded = decode_ascii_hex(&stream.content);
if filters.len() == 1 {
if decoded.len() > remaining {
return Err(Error::LimitExceeded(format!(
"PDF page content stream exceeds {content_limit} bytes"
)));
}
decoded
} else {
let mut rest = stream.clone();
rest.dict.set(
"Filter",
Object::Array(
filters[1..]
.iter()
.map(|filter| Object::Name(filter.to_vec()))
.collect(),
),
);
rest.content = decoded;
decode_stream_leniently(&rest, remaining, content_limit)?
}
} else {
decode_stream_leniently(stream, remaining, content_limit)?
};
content.extend_from_slice(&data);
content.push(b'\n');
}
Ok(content)
}
fn render_page(
document: &Document,
page_number: usize,
page_id: ObjectId,
content_limit: usize,
outline_embedded_pdf_text: bool,
decrypted_in_place: bool,
) -> Result<Page> {
let box_values = inherited_page_array(document, page_id, b"CropBox")
.or_else(|| inherited_page_array(document, page_id, b"MediaBox"))
.unwrap_or([0.0, 0.0, 612.0, 792.0]);
let rotation = inherited_page_number(document, page_id, b"Rotate")
.unwrap_or(0.0)
.round() as i32;
let rotation = rotation.rem_euclid(360);
let user_unit = page_user_unit(document, page_id)?;
let source_width = (box_values[2] - box_values[0]).abs().max(1.0) * user_unit;
let source_height = (box_values[3] - box_values[1]).abs().max(1.0) * user_unit;
let (width, height) = if matches!(rotation, 90 | 270) {
(source_height, source_width)
} else {
(source_width, source_height)
};
let page_matrix = compose(
[user_unit, 0.0, 0.0, user_unit, 0.0, 0.0],
page_matrix(box_values, rotation),
);
let mut page = Page::new(page_number, width, height, "pdf");
page.description = format!("PDF page {page_number} converted from content operators");
let content_bytes = page_content_with_limit(document, page_id, content_limit)?;
let operations = decode_content_operations(&content_bytes, content_limit, &mut page)?;
let fonts = build_font_decoders(document, page_id, content_limit, &mut page)?;
let mut interpreter = Interpreter {
document,
page: &mut page,
page_matrix,
page_id,
fonts,
resources: Vec::new(),
state: GraphicsState::default(),
content_base_ctm: IDENTITY,
stack: Vec::new(),
path: PathBuilder::default(),
pending_clip_rule: None,
text: TextState::default(),
node_counter: 0,
clip_counter: 0,
mask_counter: 0,
compatibility_depth: 0,
visited_forms: HashSet::new(),
visited_patterns: HashSet::new(),
mesh_output_count: Rc::new(Cell::new(0)),
hidden_ocgs: hidden_optional_content_groups(document),
marked_content_hidden: false,
marked_content_stack: Vec::new(),
content_limit,
outline_embedded_pdf_text,
};
interpreter.interpret(&operations, 0)?;
if !interpreter.stack.is_empty() {
interpreter
.page
.warn("PDF graphics-state stack was not balanced at end of page");
}
interpreter.render_annotations()?;
let content_is_blank = content_bytes
.iter()
.all(|byte| byte.is_ascii_whitespace() || *byte == 0);
if page.nodes.is_empty() && !content_is_blank {
if decrypted_in_place {
page.warn(format!(
"PDF page {page_number} drew nothing from {} byte(s) of content; the document was encrypted and this stream was probably not decrypted",
content_bytes.len()
));
} else {
page.warn(format!(
"PDF page {page_number} drew nothing from {} byte(s) of content",
content_bytes.len()
));
}
}
Ok(page)
}
fn decode_content_operations(
data: &[u8],
content_limit: usize,
page: &mut Page,
) -> Result<Vec<Operation>> {
let mut operations = Vec::new();
let mut position = 0usize;
while let Some(inline_start) = find_pdf_syntax_token(data, position, b"BI") {
if inline_start > position {
operations.extend(decode_pdf_content(&data[position..inline_start])?.operations);
}
let dictionary_start = inline_start + 2;
let Some(id_position) = find_pdf_syntax_token(data, dictionary_start, b"ID") else {
return Err(Error::InvalidInput(
"PDF inline image has no ID delimiter".into(),
));
};
let spec = InlineImageSpec::parse(&data[dictionary_start..id_position]);
let data_start = skip_inline_separator(data, id_position + 2);
let mut decoded = None::<(Stream, usize)>;
if spec.filter.is_none()
&& let Some(length) = spec.unfiltered_byte_length()
{
let candidate_end = data_start.saturating_add(length);
if candidate_end <= data.len() {
let after_data = skip_inline_separator(data, candidate_end);
if data.get(after_data..after_data + 2) == Some(b"EI")
&& data
.get(after_data + 2)
.is_none_or(|byte| is_pdf_delimiter(*byte))
{
decoded = spec
.decode(&data[data_start..candidate_end], content_limit)
.map(|stream| (stream, after_data + 2));
}
}
}
if decoded.is_none() {
let mut search_position = data_start;
while let Some(ei_position) = find_pdf_token(data, search_position, b"EI") {
let raw_end = trim_inline_data_end(data, data_start, ei_position);
if let Some(stream) = spec.decode(&data[data_start..raw_end], content_limit) {
decoded = Some((stream, ei_position + 2));
break;
}
search_position = ei_position + 2;
}
}
if let Some((stream, next_position)) = decoded {
operations.push(Operation::new("BI", vec![Object::Stream(stream)]));
position = next_position;
} else if let Some(ei_position) = find_pdf_token(data, data_start, b"EI") {
page.warn(format!(
"PDF inline image filter {} could not be recovered",
spec.filter.as_deref().unwrap_or("unfiltered")
));
position = ei_position + 2;
} else {
return Err(Error::InvalidInput(
"PDF inline image has no EI delimiter".into(),
));
}
}
if position < data.len() {
operations.extend(decode_pdf_content(&data[position..])?.operations);
}
Ok(operations)
}
fn decode_pdf_content(data: &[u8]) -> Result<Content> {
Ok(Content::decode(&strip_pdf_content_comments(data))?)
}
fn strip_pdf_content_comments(data: &[u8]) -> Vec<u8> {
let mut output = Vec::with_capacity(data.len());
let mut position = 0;
let mut literal_depth = 0usize;
let mut in_hex_string = false;
while position < data.len() {
let byte = data[position];
if in_hex_string {
output.push(byte);
in_hex_string = byte != b'>';
position += 1;
continue;
}
if literal_depth > 0 {
if byte == b'\\' && position + 1 < data.len() {
output.push(byte);
output.push(data[position + 1]);
position += 2;
continue;
}
output.push(byte);
match byte {
b'(' => literal_depth += 1,
b')' => literal_depth -= 1,
_ => {}
}
position += 1;
continue;
}
match byte {
b'(' => {
literal_depth = 1;
output.push(byte);
position += 1;
}
b'<' if data.get(position + 1) == Some(&b'<') => {
output.push(byte);
output.push(data[position + 1]);
position += 2;
}
b'<' => {
in_hex_string = true;
output.push(byte);
position += 1;
}
b'%' => {
while position < data.len() && !matches!(data[position], b'\r' | b'\n') {
position += 1;
}
}
_ => {
output.push(byte);
position += 1;
}
}
}
output
}
fn find_pdf_syntax_token(data: &[u8], start: usize, token: &[u8]) -> Option<usize> {
let mut position = start;
let mut literal_depth = 0usize;
let mut escaped = false;
let mut hex_string = false;
let mut comment = false;
while position < data.len() {
let byte = data[position];
if comment {
if matches!(byte, b'\r' | b'\n') {
comment = false;
}
position += 1;
continue;
}
if literal_depth > 0 {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'(' {
literal_depth += 1;
} else if byte == b')' {
literal_depth -= 1;
}
position += 1;
continue;
}
if hex_string {
if byte == b'>' {
hex_string = false;
}
position += 1;
continue;
}
match byte {
b'%' => {
comment = true;
position += 1;
continue;
}
b'(' => {
literal_depth = 1;
position += 1;
continue;
}
b'<' if data.get(position + 1) != Some(&b'<') => {
hex_string = true;
position += 1;
continue;
}
_ => {}
}
if data.get(position..position + token.len()) == Some(token) {
let before = position
.checked_sub(1)
.and_then(|index| data.get(index))
.is_none_or(|byte| is_pdf_delimiter(*byte));
let after = data
.get(position + token.len())
.is_none_or(|byte| is_pdf_delimiter(*byte));
if before && after {
return Some(position);
}
}
position += 1;
}
None
}
fn find_pdf_token(data: &[u8], start: usize, token: &[u8]) -> Option<usize> {
let mut search = start;
while search + token.len() <= data.len() {
let offset = data
.get(search..)?
.windows(token.len())
.position(|window| window == token)?;
let position = search + offset;
let delimited = {
let before = position
.checked_sub(1)
.and_then(|index| data.get(index))
.is_none_or(|byte| is_pdf_delimiter(*byte));
let after = data
.get(position + token.len())
.is_none_or(|byte| is_pdf_delimiter(*byte));
before && after
};
if delimited {
return Some(position);
}
search = position + 1;
}
None
}
fn is_pdf_delimiter(byte: u8) -> bool {
byte.is_ascii_whitespace() || matches!(byte, b'[' | b']' | b'<' | b'>' | b'(' | b')' | b'/')
}
fn skip_inline_separator(data: &[u8], mut position: usize) -> usize {
if data.get(position..position + 2) == Some(b"\r\n") {
return position + 2;
}
if data
.get(position)
.is_some_and(|byte| byte.is_ascii_whitespace())
{
position += 1;
}
position
}
fn trim_inline_data_end(data: &[u8], start: usize, mut end: usize) -> usize {
if end > start && data[end - 1] == b'\n' {
end -= 1;
if end > start && data[end - 1] == b'\r' {
end -= 1;
}
} else if end > start && data[end - 1].is_ascii_whitespace() {
end -= 1;
}
end
}
#[derive(Default)]
struct InlineImageSpec {
width: usize,
height: usize,
bits: usize,
color_space: String,
filter: Option<String>,
k: i64,
columns: usize,
rows: usize,
end_of_block: bool,
end_of_line: bool,
byte_aligned: bool,
black_is_one: bool,
damaged_rows_before_error: usize,
interpolate: bool,
image_mask: bool,
}
impl InlineImageSpec {
fn parse(data: &[u8]) -> Self {
let tokens = inline_dictionary_tokens(data);
let value = |names: &[&str]| {
tokens.iter().enumerate().find_map(|(index, token)| {
names
.contains(&token.as_str())
.then(|| tokens.get(index + 1).cloned())
.flatten()
})
};
let integer = |names: &[&str], default: i64| {
value(names)
.and_then(|value| value.parse().ok())
.unwrap_or(default)
};
let boolean =
|names: &[&str], default: bool| value(names).map_or(default, |value| value == "true");
let width = integer(&["/W", "/Width"], 0).max(0) as usize;
let height = integer(&["/H", "/Height"], 0).max(0) as usize;
Self {
width,
height,
bits: integer(&["/BPC", "/BitsPerComponent"], 8).max(1) as usize,
color_space: value(&["/CS", "/ColorSpace"]).unwrap_or_else(|| "/G".into()),
filter: value(&["/F", "/Filter"]),
k: integer(&["/K"], 0),
columns: integer(&["/Columns"], width as i64).max(0) as usize,
rows: integer(&["/Rows"], height as i64).max(0) as usize,
end_of_block: boolean(&["/EndOfBlock"], true),
end_of_line: boolean(&["/EndOfLine"], false),
byte_aligned: boolean(&["/EncodedByteAlign"], false),
black_is_one: boolean(&["/BlackIs1"], false),
damaged_rows_before_error: integer(&["/DamagedRowsBeforeError"], 0).max(0) as usize,
interpolate: boolean(&["/I", "/Interpolate"], false),
image_mask: boolean(&["/IM", "/ImageMask"], false),
}
}
fn components(&self) -> usize {
match self.color_space.as_str() {
"/RGB" | "/DeviceRGB" => 3,
"/CMYK" | "/DeviceCMYK" => 4,
_ => 1,
}
}
fn unfiltered_byte_length(&self) -> Option<usize> {
if self.width == 0 || self.height == 0 {
return None;
}
let row_bytes = self
.width
.saturating_mul(self.components())
.saturating_mul(self.bits)
.div_ceil(8);
Some(row_bytes.saturating_mul(self.height))
}
fn decode(&self, raw: &[u8], content_limit: usize) -> Option<Stream> {
if self.width == 0 || self.height == 0 {
return None;
}
let filter = self.filter.as_deref().unwrap_or("");
let pixels = if matches!(filter, "/CCF" | "/CCITTFaxDecode") {
self.decode_ccitt(raw)?
} else if filter.is_empty() {
raw.to_vec()
} else {
let filter_name = match filter {
"/LZW" | "/LZWDecode" => b"LZWDecode".to_vec(),
"/Fl" | "/FlateDecode" => b"FlateDecode".to_vec(),
"/RL" | "/RunLengthDecode" => b"RunLengthDecode".to_vec(),
"/A85" | "/ASCII85Decode" => b"ASCII85Decode".to_vec(),
"/AHx" | "/ASCIIHexDecode" => b"ASCIIHexDecode".to_vec(),
_ => return None,
};
let mut dictionary = Dictionary::new();
dictionary.set("Filter", Object::Name(filter_name));
Stream::new(dictionary, raw.to_vec())
.decompressed_content_with_limit(content_limit)
.ok()?
};
let components = self.components();
let expected = self
.width
.saturating_mul(self.height)
.saturating_mul(components)
.saturating_mul(self.bits)
.div_ceil(8);
if self.bits == 8 && pixels.len() < expected {
return None;
}
let mut dictionary = Dictionary::new();
dictionary.set("Subtype", Object::Name(b"Image".to_vec()));
dictionary.set("Width", self.width as i64);
dictionary.set("Height", self.height as i64);
dictionary.set(
"BitsPerComponent",
if filter.contains("CCF") {
8
} else {
self.bits as i64
},
);
dictionary.set(
"ColorSpace",
Object::Name(match self.color_space.as_str() {
"/RGB" | "/DeviceRGB" => b"DeviceRGB".to_vec(),
"/CMYK" | "/DeviceCMYK" => b"DeviceCMYK".to_vec(),
_ => b"DeviceGray".to_vec(),
}),
);
dictionary.set("Interpolate", self.interpolate);
if self.image_mask {
dictionary.set("ImageMask", true);
}
Some(Stream::new(dictionary, pixels))
}
fn decode_ccitt(&self, raw: &[u8]) -> Option<Vec<u8>> {
let mode = if self.k < 0 {
hayro_ccitt::EncodingMode::Group4
} else if self.k == 0 {
hayro_ccitt::EncodingMode::Group3_1D
} else {
hayro_ccitt::EncodingMode::Group3_2D { k: self.k as u32 }
};
let settings = hayro_ccitt::DecodeSettings {
columns: self.columns.max(self.width) as u32,
rows: self.rows.max(self.height) as u32,
end_of_block: self.end_of_block,
end_of_line: self.end_of_line,
rows_are_byte_aligned: self.byte_aligned,
encoding: mode,
invert_black: self.black_is_one,
};
let mut decoder = GrayCcittDecoder::default();
let mut context = hayro_ccitt::DecoderContext::new(settings);
let required = self.width.saturating_mul(self.height);
if hayro_ccitt::decode(raw, &mut decoder, &mut context).is_ok()
&& decoder.pixels.len() >= required
{
decoder.pixels.truncate(required);
return Some(decoder.pixels);
}
if self.damaged_rows_before_error == 0
|| !self.end_of_line
|| self.k <= 0
|| self.byte_aligned
{
return None;
}
let reference_row = decoder
.rows
.checked_sub(1)
.and_then(|row| {
let start = row.checked_mul(self.width)?;
decoder.pixels.get(start..start.checked_add(self.width)?)
})
.map(<[u8]>::to_vec);
repair_group3_2d_damaged_row(
raw,
settings,
required,
decoder.rows,
reference_row.as_deref(),
)
}
}
fn ccitt_spec_from_stream(stream: &Stream, width: usize, height: usize) -> InlineImageSpec {
let parameters = stream
.dict
.get(b"DecodeParms")
.or_else(|_| stream.dict.get(b"DP"))
.ok()
.and_then(|value| match value {
Object::Dictionary(dictionary) => Some(dictionary),
Object::Array(values) => values.first().and_then(|value| value.as_dict().ok()),
_ => None,
});
let integer = |name: &[u8], default: i64| {
parameters
.and_then(|dictionary| dictionary.get(name).and_then(Object::as_i64).ok())
.unwrap_or(default)
};
let boolean = |name: &[u8], default: bool| {
parameters
.and_then(|dictionary| dictionary.get(name).and_then(Object::as_bool).ok())
.unwrap_or(default)
};
InlineImageSpec {
width,
height,
bits: 8,
color_space: "/G".into(),
filter: Some("/CCITTFaxDecode".into()),
k: integer(b"K", 0),
columns: integer(b"Columns", width as i64).max(1) as usize,
rows: integer(b"Rows", height as i64).max(1) as usize,
end_of_block: boolean(b"EndOfBlock", true),
end_of_line: boolean(b"EndOfLine", false),
byte_aligned: boolean(b"EncodedByteAlign", false),
black_is_one: boolean(b"BlackIs1", false),
damaged_rows_before_error: integer(b"DamagedRowsBeforeError", 0).max(0) as usize,
interpolate: stream
.dict
.get(b"Interpolate")
.and_then(Object::as_bool)
.unwrap_or(false),
image_mask: false,
}
}
fn repair_group3_2d_damaged_row(
raw: &[u8],
settings: hayro_ccitt::DecodeSettings,
required_pixels: usize,
failing_row: usize,
reference_row: Option<&[u8]>,
) -> Option<Vec<u8>> {
let bit_length = raw.len().checked_mul(8)?;
let mut eol_positions = Vec::new();
for position in 0..bit_length.saturating_sub(11) {
if (0..11).all(|offset| packed_bit(raw, position + offset) == 0)
&& packed_bit(raw, position + 11) == 1
{
eol_positions.push(position);
if eol_positions.len() > settings.rows as usize + 6 {
break;
}
}
}
let row_count = settings.rows as usize;
let mut candidates = vec![failing_row];
if failing_row > 0 {
candidates.push(failing_row - 1);
}
if failing_row + 1 < row_count {
candidates.push(failing_row + 1);
}
for candidate in candidates {
let Some(&start) = eol_positions.get(candidate) else {
continue;
};
let Some(&end) = eol_positions.get(candidate + 1) else {
continue;
};
if candidate == failing_row
&& let Some(reference_row) = reference_row
{
let mut repaired = Vec::with_capacity(raw.len());
let mut repaired_bits = 0usize;
append_packed_bits(raw, 0, start, &mut repaired, &mut repaired_bits);
for _ in 0..11 {
push_packed_bit(&mut repaired, &mut repaired_bits, 0);
}
push_packed_bit(&mut repaired, &mut repaired_bits, 1);
push_packed_bit(&mut repaired, &mut repaired_bits, 0);
let black_runs = reference_row
.iter()
.enumerate()
.filter(|(index, pixel)| {
**pixel == 0 && (*index == 0 || reference_row[*index - 1] != 0)
})
.count();
if black_runs == 0 {
push_packed_bit(&mut repaired, &mut repaired_bits, 1);
} else {
for _ in 0..black_runs {
for bit in [0, 0, 0, 1] {
push_packed_bit(&mut repaired, &mut repaired_bits, bit);
}
}
}
append_packed_bits(raw, end, bit_length, &mut repaired, &mut repaired_bits);
if let Some(pixels) = decode_repaired_ccitt(&repaired, settings, required_pixels) {
return Some(pixels);
}
}
let mut replacements = Vec::new();
if candidate > 0 {
replacements.push((eol_positions[candidate - 1], start));
}
if let Some(&next_end) = eol_positions.get(candidate + 2) {
replacements.push((end, next_end));
}
for (replacement_start, replacement_end) in replacements {
let mut repaired = Vec::with_capacity(raw.len());
let mut repaired_bits = 0usize;
append_packed_bits(raw, 0, start, &mut repaired, &mut repaired_bits);
append_packed_bits(
raw,
replacement_start,
replacement_end,
&mut repaired,
&mut repaired_bits,
);
append_packed_bits(raw, end, bit_length, &mut repaired, &mut repaired_bits);
if let Some(pixels) = decode_repaired_ccitt(&repaired, settings, required_pixels) {
return Some(pixels);
}
}
let mut repaired = Vec::with_capacity(raw.len());
let mut repaired_bits = 0usize;
append_packed_bits(raw, 0, start, &mut repaired, &mut repaired_bits);
for _ in 0..11 {
push_packed_bit(&mut repaired, &mut repaired_bits, 0);
}
push_packed_bit(&mut repaired, &mut repaired_bits, 1);
push_packed_bit(&mut repaired, &mut repaired_bits, 0);
push_packed_bit(&mut repaired, &mut repaired_bits, 1);
append_packed_bits(raw, end, bit_length, &mut repaired, &mut repaired_bits);
if let Some(pixels) = decode_repaired_ccitt(&repaired, settings, required_pixels) {
return Some(pixels);
}
}
None
}
fn decode_repaired_ccitt(
data: &[u8],
settings: hayro_ccitt::DecodeSettings,
required_pixels: usize,
) -> Option<Vec<u8>> {
let mut decoder = GrayCcittDecoder::default();
let mut context = hayro_ccitt::DecoderContext::new(settings);
hayro_ccitt::decode(data, &mut decoder, &mut context).ok()?;
if decoder.pixels.len() < required_pixels {
return None;
}
decoder.pixels.truncate(required_pixels);
Some(decoder.pixels)
}
fn packed_bit(data: &[u8], position: usize) -> u8 {
(data[position / 8] >> (7 - position % 8)) & 1
}
fn append_packed_bits(
source: &[u8],
start: usize,
end: usize,
output: &mut Vec<u8>,
output_bits: &mut usize,
) {
for position in start..end {
push_packed_bit(output, output_bits, packed_bit(source, position));
}
}
fn push_packed_bit(output: &mut Vec<u8>, bit_length: &mut usize, bit: u8) {
if bit_length.is_multiple_of(8) {
output.push(0);
}
if bit != 0 {
let index = output.len() - 1;
output[index] |= 1 << (7 - *bit_length % 8);
}
*bit_length += 1;
}
fn inline_dictionary_tokens(data: &[u8]) -> Vec<String> {
let mut tokens = Vec::new();
let mut position = 0usize;
while position < data.len() {
while data
.get(position)
.is_some_and(|byte| byte.is_ascii_whitespace())
{
position += 1;
}
if position >= data.len() {
break;
}
let start = position;
if data[position] == b'/' {
position += 1;
while position < data.len() && !is_pdf_delimiter(data[position]) {
position += 1;
}
tokens.push(String::from_utf8_lossy(&data[start..position]).into_owned());
} else if position + 1 < data.len()
&& matches!(&data[position..position + 2], b"<<" | b">>")
{
position += 2;
tokens.push(String::from_utf8_lossy(&data[start..position]).into_owned());
} else {
while position < data.len() && !is_pdf_delimiter(data[position]) {
position += 1;
}
if position == start {
position += 1;
}
tokens.push(String::from_utf8_lossy(&data[start..position]).into_owned());
}
}
tokens
}
#[derive(Default)]
struct GrayCcittDecoder {
pixels: Vec<u8>,
rows: usize,
}
impl hayro_ccitt::Decoder for GrayCcittDecoder {
fn push_pixel(&mut self, white: bool) {
self.pixels.push(if white { 255 } else { 0 });
}
fn push_pixel_chunk(&mut self, white: bool, chunk_count: u32) {
self.pixels.extend(std::iter::repeat_n(
if white { 255 } else { 0 },
chunk_count as usize * 8,
));
}
fn next_line(&mut self) {
self.rows += 1;
}
}
#[derive(Clone, Debug)]
struct GraphicsState {
ctm: Matrix,
fill: Paint,
stroke: Paint,
fill_color_space: Object,
stroke_color_space: Object,
fill_pattern_clip: Option<PatternClip>,
stroke_pattern_clip: Option<PatternClip>,
line_width: f64,
line_cap: LineCap,
line_join: LineJoin,
miter_limit: f64,
dash_array: Vec<f64>,
dash_offset: f64,
fill_alpha: f64,
stroke_alpha: f64,
clip_id: Option<String>,
blend_mode: String,
mask_id: Option<String>,
alpha_is_shape: bool,
}
impl Default for GraphicsState {
fn default() -> Self {
Self {
ctm: IDENTITY,
fill: Paint::solid("#000000"),
stroke: Paint::solid("#000000"),
fill_color_space: Object::Name(b"DeviceGray".to_vec()),
stroke_color_space: Object::Name(b"DeviceGray".to_vec()),
fill_pattern_clip: None,
stroke_pattern_clip: None,
line_width: 1.0,
line_cap: LineCap::Butt,
line_join: LineJoin::Miter,
miter_limit: 10.0,
dash_array: Vec::new(),
dash_offset: 0.0,
fill_alpha: 1.0,
stroke_alpha: 1.0,
clip_id: None,
blend_mode: "normal".into(),
mask_id: None,
alpha_is_shape: false,
}
}
}
#[derive(Clone, Debug, PartialEq)]
struct PatternClip {
bbox: [f64; 4],
matrix: Matrix,
}
#[derive(Clone, Debug)]
struct TextState {
font_name: Vec<u8>,
font_size: f64,
text_matrix: Matrix,
line_matrix: Matrix,
character_spacing: f64,
word_spacing: f64,
horizontal_scale: f64,
leading: f64,
rise: f64,
rendering_mode: i64,
pending_clips: Vec<PendingTextClip>,
}
#[derive(Clone, Debug)]
struct SavedTextParameters {
font_name: Vec<u8>,
font_size: f64,
character_spacing: f64,
word_spacing: f64,
horizontal_scale: f64,
leading: f64,
rise: f64,
rendering_mode: i64,
}
#[derive(Clone, Debug)]
struct SavedGraphicsState {
graphics: GraphicsState,
text: SavedTextParameters,
}
impl TextState {
fn saved_parameters(&self) -> SavedTextParameters {
SavedTextParameters {
font_name: self.font_name.clone(),
font_size: self.font_size,
character_spacing: self.character_spacing,
word_spacing: self.word_spacing,
horizontal_scale: self.horizontal_scale,
leading: self.leading,
rise: self.rise,
rendering_mode: self.rendering_mode,
}
}
fn restore_parameters(&mut self, saved: SavedTextParameters) {
self.font_name = saved.font_name;
self.font_size = saved.font_size;
self.character_spacing = saved.character_spacing;
self.word_spacing = saved.word_spacing;
self.horizontal_scale = saved.horizontal_scale;
self.leading = saved.leading;
self.rise = saved.rise;
self.rendering_mode = saved.rendering_mode;
}
}
#[derive(Clone, Debug)]
struct PendingTextClip {
d: String,
transform: Matrix,
}
impl Default for TextState {
fn default() -> Self {
Self {
font_name: Vec::new(),
font_size: 12.0,
text_matrix: IDENTITY,
line_matrix: IDENTITY,
character_spacing: 0.0,
word_spacing: 0.0,
horizontal_scale: 1.0,
leading: 0.0,
rise: 0.0,
rendering_mode: 0,
pending_clips: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default)]
struct PathBuilder {
data: String,
has_content: bool,
current_point: Option<(f64, f64)>,
subpath_start: Option<(f64, f64)>,
bare_rect: Option<[f64; 4]>,
}
impl PathBuilder {
fn command(&mut self, operator: &str, values: &[f64]) {
self.bare_rect = None;
if self.has_content {
self.data.push(' ');
}
self.data.push_str(operator);
for value in values {
self.data.push(' ');
self.data.push_str(&fmt(*value));
}
self.has_content = true;
match operator {
"M" if values.len() >= 2 => {
let point = (values[0], values[1]);
self.current_point = Some(point);
self.subpath_start = Some(point);
}
"L" if values.len() >= 2 => {
self.current_point = Some((values[0], values[1]));
}
"C" if values.len() >= 6 => {
self.current_point = Some((values[4], values[5]));
}
"Z" => self.current_point = self.subpath_start,
_ => {}
}
}
fn add_rect(&mut self, x: f64, y: f64, width: f64, height: f64) {
let is_first = !self.has_content;
self.command("M", &[x, y]);
self.command("L", &[x + width, y]);
self.command("L", &[x + width, y + height]);
self.command("L", &[x, y + height]);
self.command("Z", &[]);
if is_first {
self.bare_rect = Some([x, y, width, height]);
}
}
fn take(&mut self) -> String {
self.has_content = false;
self.current_point = None;
self.subpath_start = None;
self.bare_rect = None;
std::mem::take(&mut self.data)
}
fn clear(&mut self) {
self.data.clear();
self.has_content = false;
self.current_point = None;
self.subpath_start = None;
self.bare_rect = None;
}
}
#[derive(Default)]
struct Type1Cache(std::cell::OnceCell<Option<Box<stet_fonts::type1_parser::Type1Font>>>);
impl Clone for Type1Cache {
fn clone(&self) -> Self {
Self::default()
}
}
impl std::fmt::Debug for Type1Cache {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Type1Cache")
.field("parsed", &self.0.get().is_some())
.finish()
}
}
#[derive(Clone, Debug)]
struct FontDecoder {
family: String,
bold: bool,
italic: bool,
requires_outline: bool,
font_data: Option<Arc<[u8]>>,
glyph_names: HashMap<u8, String>,
unicode_map: HashMap<Vec<u8>, String>,
code_lengths: Vec<usize>,
fallback_kind: FontFallback,
widths: HashMap<u32, f64>,
default_width: f64,
type3: Option<Arc<Type3Font>>,
type1: Type1Cache,
identity_cid: bool,
cid_to_gid_map: Option<Arc<[u8]>>,
}
#[derive(Clone, Debug)]
struct Type3Font {
font_matrix: Matrix,
resources: Option<Dictionary>,
glyphs: HashMap<u8, Stream>,
}
#[derive(Clone, Copy, Debug)]
enum FontFallback {
OneByte,
Utf16Be,
}
fn strip_pfb_segments(data: &[u8]) -> std::borrow::Cow<'_, [u8]> {
if data.first() != Some(&0x80) {
return std::borrow::Cow::Borrowed(data);
}
let mut out = Vec::with_capacity(data.len());
let mut offset = 0;
while data.get(offset) == Some(&0x80) {
match data.get(offset + 1) {
Some(1 | 2) => {
let Some(length_bytes) = data.get(offset + 2..offset + 6) else {
break;
};
let length = u32::from_le_bytes(length_bytes.try_into().unwrap()) as usize;
let start = offset + 6;
let end = start.saturating_add(length).min(data.len());
out.extend_from_slice(&data[start..end]);
offset = end;
}
_ => break,
}
}
std::borrow::Cow::Owned(out)
}
impl FontDecoder {
fn decode(&self, bytes: &[u8]) -> (String, bool) {
if !self.unicode_map.is_empty() {
let mut result = String::new();
let mut position = 0usize;
let mut replacement = false;
while position < bytes.len() {
let mut matched = false;
for length in &self.code_lengths {
if position + length > bytes.len() {
continue;
}
if let Some(value) = self.unicode_map.get(&bytes[position..position + length]) {
result.push_str(value);
position += length;
matched = true;
break;
}
}
if !matched {
result.push('\u{fffd}');
position += 1;
replacement = true;
}
}
return (result, replacement);
}
match self.fallback_kind {
FontFallback::OneByte => (bytes.iter().map(|byte| char::from(*byte)).collect(), false),
FontFallback::Utf16Be => {
let units = bytes
.chunks_exact(2)
.map(|pair| u16::from_be_bytes([pair[0], pair[1]]));
let mut replacement = !bytes.len().is_multiple_of(2);
let result = char::decode_utf16(units)
.map(|item| match item {
Ok(character) => character,
Err(_) => {
replacement = true;
'\u{fffd}'
}
})
.collect();
(result, replacement)
}
}
}
fn width(&self, bytes: &[u8]) -> f64 {
let code_length = if matches!(self.fallback_kind, FontFallback::Utf16Be) {
self.code_lengths.first().copied().unwrap_or(2).max(1)
} else {
1
};
bytes
.chunks(code_length)
.map(|code| {
self.widths
.get(&bytes_to_u32(code))
.copied()
.unwrap_or(self.default_width)
})
.sum::<f64>()
/ 1_000.0
}
fn code_count(&self, bytes: &[u8]) -> usize {
let code_length = if matches!(self.fallback_kind, FontFallback::Utf16Be) {
self.code_lengths.first().copied().unwrap_or(2).max(1)
} else {
1
};
bytes.len().div_ceil(code_length)
}
fn glyph_x_offsets(
&self,
bytes: &[u8],
font_size: f64,
character_spacing: f64,
word_spacing: f64,
horizontal_scale: f64,
) -> Option<Vec<f64>> {
let code_length = if matches!(self.fallback_kind, FontFallback::Utf16Be) {
self.code_lengths.first().copied().unwrap_or(2).max(1)
} else {
1
};
let inverse_horizontal_scale = 1.0 / horizontal_scale.abs().max(1e-12);
let mut offsets = Vec::with_capacity(bytes.len().div_ceil(code_length));
let mut x = 0.0;
for code in bytes.chunks(code_length) {
let (decoded, replacement) = self.decode(code);
let mut characters = decoded.chars();
let character = characters.next()?;
if replacement || characters.next().is_some() {
return None;
}
offsets.push(x);
let width = self
.widths
.get(&bytes_to_u32(code))
.copied()
.unwrap_or(self.default_width)
/ 1_000.0
* font_size;
let spacing = character_spacing + if character == ' ' { word_spacing } else { 0.0 };
x += width + spacing * inverse_horizontal_scale;
}
Some(offsets)
}
fn outline_path(
&self,
bytes: &[u8],
character_spacing_em: f64,
word_spacing_em: f64,
) -> std::result::Result<String, &'static str> {
let data = self
.font_data
.as_ref()
.ok_or("embedded font program is unavailable")?;
let face = match FontRef::from_index(data, 0) {
Ok(face) => face,
Err(_) => {
return self.outline_type1(data, bytes, character_spacing_em, word_spacing_em);
}
};
let units_per_em = f64::from(
face.head()
.map_err(|_| "embedded font has no valid head table")?
.units_per_em(),
)
.max(1.0);
let charmap = face.charmap();
let cmap = face.cmap().ok();
let outlines = face.outline_glyphs();
let post_glyph_ids: Option<HashMap<String, skrifa::GlyphId>> =
face.post().ok().map(|post| {
(0..post.num_names() as u16)
.filter_map(|gid| {
post.glyph_name(skrifa::raw::types::GlyphId16::new(gid))
.map(|name| (name.to_string(), skrifa::GlyphId::from(gid)))
})
.collect()
});
let code_length = if matches!(self.fallback_kind, FontFallback::Utf16Be) {
self.code_lengths.first().copied().unwrap_or(2).max(1)
} else {
1
};
let cff_cid_to_gid = self
.identity_cid
.then(|| {
face.data_for_tag(Tag::new(b"CFF "))
.and_then(|table| stet_fonts::cff_parser::parse_cff(table.as_bytes()).ok())
.and_then(|fonts| fonts.into_iter().next())
.map(|font| font.cid_to_gid)
})
.flatten();
let mut builder = SvgGlyphOutline::new(units_per_em);
let mut x_offset = 0.0;
let mut has_visible_character = false;
for code in bytes.chunks(code_length) {
let (decoded, replacement) = self.decode(code);
builder.x_offset = x_offset;
if self.identity_cid {
has_visible_character |=
replacement || decoded.chars().any(|character| !character.is_whitespace());
let cid = bytes_to_u32(code);
let gid = if let Some(cid_to_gid) = cff_cid_to_gid.as_ref() {
cid_to_gid
.get(cid as usize)
.copied()
.map(u32::from)
.unwrap_or(0)
} else {
self.cid_to_gid_map.as_ref().map_or(cid, |map| {
let offset = (cid as usize) * 2;
map.get(offset..offset + 2)
.map(|pair| u32::from(u16::from_be_bytes([pair[0], pair[1]])))
.unwrap_or(0)
})
};
let glyph = outlines
.get(skrifa::GlyphId::new(gid))
.ok_or("embedded font glyph outline is unavailable")?;
glyph
.draw(
DrawSettings::unhinted(Size::unscaled(), LocationRef::default()),
&mut builder,
)
.map_err(|_| "embedded font glyph outline could not be decoded")?;
} else {
for character in decoded.chars() {
has_visible_character |= !character.is_whitespace();
let glyph_id = if self.glyph_names.is_empty() {
cmap.as_ref()
.and_then(|cmap| map_embedded_cmap(cmap, bytes_to_u32(code)))
.or_else(|| charmap.map(character))
.or_else(|| {
cmap.as_ref()
.and_then(|cmap| map_embedded_cmap(cmap, u32::from(character)))
})
} else {
charmap
.map(character)
.or_else(|| {
cmap.as_ref()
.and_then(|cmap| map_embedded_cmap(cmap, u32::from(character)))
})
.or_else(|| {
cmap.as_ref()
.and_then(|cmap| map_embedded_cmap(cmap, bytes_to_u32(code)))
})
}
.or_else(|| {
code.first()
.and_then(|code| self.glyph_names.get(code))
.and_then(|name| post_glyph_ids.as_ref()?.get(name.as_str()).copied())
})
.ok_or("Unicode character has no glyph in the embedded font")?;
let glyph = outlines
.get(glyph_id)
.ok_or("embedded font glyph outline is unavailable")?;
glyph
.draw(
DrawSettings::unhinted(Size::unscaled(), LocationRef::default()),
&mut builder,
)
.map_err(|_| "embedded font glyph outline could not be decoded")?;
}
}
let word_spacing = if decoded == " " { word_spacing_em } else { 0.0 };
x_offset += self
.widths
.get(&bytes_to_u32(code))
.copied()
.unwrap_or(self.default_width)
/ 1_000.0
+ character_spacing_em
+ word_spacing;
}
if builder.path.is_empty() {
if has_visible_character {
Err("text contains no visible glyph outlines")
} else {
Ok(String::new())
}
} else {
Ok(builder.path)
}
}
fn outline_type1(
&self,
data: &[u8],
bytes: &[u8],
character_spacing_em: f64,
word_spacing_em: f64,
) -> std::result::Result<String, &'static str> {
let Some(font) = self
.type1
.0
.get_or_init(|| {
stet_fonts::type1_parser::parse_type1(&strip_pfb_segments(data))
.ok()
.map(Box::new)
})
.as_deref()
else {
return self.outline_cff(data, bytes, character_spacing_em, word_spacing_em);
};
let lookup = |name: &str| font.charstrings.get(name).cloned();
let mut path = String::new();
let mut x_offset = 0.0;
let mut has_visible_character = false;
let code_length = if self.identity_cid { 2 } else { 1 };
for code in bytes.chunks(code_length) {
let (decoded, _replacement) = self.decode(code);
has_visible_character |= decoded.chars().any(|character| !character.is_whitespace());
let glyph_name = if self.identity_cid {
font.encoding
.get(bytes_to_u32(code) as usize)
.map(String::as_str)
} else {
self.glyph_names
.get(&code[0])
.map(String::as_str)
.or_else(|| font.encoding.get(usize::from(code[0])).map(String::as_str))
}
.filter(|name| !name.is_empty() && *name != ".notdef")
.or_else(|| (!decoded.is_empty()).then_some(decoded.as_str()))
.ok_or("Type1 character code has no glyph name")?;
if let Some(charstring) = font.charstrings.get(glyph_name) {
let outline = stet_fonts::charstring::execute_charstring_ex(
charstring,
&font.subrs,
font.len_iv,
false,
Some(&lookup),
)
.map_err(|_| "Type1 glyph outline could not be decoded")?;
append_stet_path(&mut path, &outline.path, font.font_matrix, x_offset);
} else if !decoded.chars().all(char::is_whitespace) {
return Err("Type1 glyph name is absent from CharStrings");
}
let word_spacing = if decoded == " " { word_spacing_em } else { 0.0 };
x_offset += self
.widths
.get(&bytes_to_u32(code))
.copied()
.unwrap_or(self.default_width)
/ 1_000.0
+ character_spacing_em
+ word_spacing;
}
if path.is_empty() {
if has_visible_character {
Err("Type1 text contains no visible glyph outlines")
} else {
Ok(String::new())
}
} else {
Ok(path)
}
}
fn outline_cff(
&self,
data: &[u8],
bytes: &[u8],
character_spacing_em: f64,
word_spacing_em: f64,
) -> std::result::Result<String, &'static str> {
let fonts = stet_fonts::cff_parser::parse_cff(data)
.map_err(|_| "embedded font program is neither OpenType, Type1, nor CFF")?;
let font = fonts.first().ok_or("CFF font set is empty")?;
let mut path = String::new();
let mut x_offset = 0.0;
let mut has_visible_character = false;
let code_length = if self.identity_cid { 2 } else { 1 };
for code in bytes.chunks(code_length) {
let (decoded, _replacement) = self.decode(code);
has_visible_character |= decoded.chars().any(|character| !character.is_whitespace());
let glyph_id = if self.identity_cid {
font.cid_to_gid
.get(bytes_to_u32(code) as usize)
.copied()
.map(usize::from)
.filter(|glyph_id| *glyph_id != 0xFFFF)
} else {
self.glyph_names
.get(&code[0])
.and_then(|name| font.charset.iter().position(|candidate| candidate == name))
.or_else(|| {
let mut characters = decoded.chars();
let character = characters.next()?;
if characters.next().is_some() || u32::from(character) > u32::from(u16::MAX)
{
return None;
}
font.charset.iter().position(|name| {
stet_fonts::agl::glyph_name_to_unicode(name) == Some(character as u16)
})
})
.or_else(|| {
font.encoding
.get(usize::from(code[0]))
.copied()
.map(usize::from)
.filter(|glyph_id| *glyph_id != 0)
})
};
if let Some(glyph_id) = glyph_id {
let charstring = font
.char_strings
.get(glyph_id)
.ok_or("CFF glyph index is outside CharStrings")?;
let (local_subrs, default_width, nominal_width, font_matrix) = if font.is_cid {
let fd_index = font
.fd_select
.get(glyph_id)
.copied()
.map(usize::from)
.unwrap_or(0);
let fd = font
.fd_array
.get(fd_index)
.ok_or("CFF CID glyph has no font dictionary")?;
(
fd.local_subrs.as_slice(),
fd.default_width_x,
fd.nominal_width_x,
fd.font_matrix.unwrap_or(font.font_matrix),
)
} else {
(
font.local_subrs.as_slice(),
font.default_width_x,
font.nominal_width_x,
font.font_matrix,
)
};
let outline = stet_fonts::type2_charstring::execute_type2_charstring(
charstring,
local_subrs,
&font.global_subrs,
default_width,
nominal_width,
false,
)
.map_err(|_| "CFF Type2 glyph outline could not be decoded")?;
if let Some((adx, ady, base_code, accent_code)) = outline.seac {
for (code, dx, dy) in [(base_code, 0.0, 0.0), (accent_code, adx, ady)] {
let Some(name) = stet_fonts::encoding::standard_encoding_name(code) else {
continue;
};
let Some(component_id) = font
.charset
.iter()
.position(|candidate| candidate.as_str() == name)
else {
continue;
};
let Some(component_charstring) = font.char_strings.get(component_id) else {
continue;
};
let Ok(component) = stet_fonts::type2_charstring::execute_type2_charstring(
component_charstring,
local_subrs,
&font.global_subrs,
default_width,
nominal_width,
false,
) else {
continue;
};
let component_matrix = compose(font_matrix, [1.0, 0.0, 0.0, 1.0, dx, dy]);
append_stet_path(&mut path, &component.path, component_matrix, x_offset);
}
} else {
append_stet_path(&mut path, &outline.path, font_matrix, x_offset);
}
} else if !decoded.chars().all(char::is_whitespace) {
return Err("CFF character code has no glyph");
}
let word_spacing = if decoded == " " { word_spacing_em } else { 0.0 };
x_offset += self
.widths
.get(&bytes_to_u32(code))
.copied()
.unwrap_or(self.default_width)
/ 1_000.0
+ character_spacing_em
+ word_spacing;
}
if path.is_empty() {
if has_visible_character {
Err("CFF text contains no visible glyph outlines")
} else {
Ok(String::new())
}
} else {
Ok(path)
}
}
}
fn map_embedded_cmap(
cmap: &skrifa::raw::tables::cmap::Cmap<'_>,
codepoint: u32,
) -> Option<skrifa::GlyphId> {
cmap.encoding_records()
.iter()
.filter_map(|record| record.subtable(cmap.offset_data()).ok())
.filter_map(|subtable| subtable.map_codepoint(codepoint))
.find(|glyph| *glyph != skrifa::GlyphId::NOTDEF)
}
struct SvgGlyphOutline {
path: String,
units_per_em: f64,
x_offset: f64,
}
impl SvgGlyphOutline {
fn new(units_per_em: f64) -> Self {
Self {
path: String::new(),
units_per_em,
x_offset: 0.0,
}
}
fn point(&self, x: f32, y: f32) -> (f64, f64) {
(
self.x_offset + f64::from(x) / self.units_per_em,
f64::from(y) / self.units_per_em,
)
}
fn command(&mut self, operator: &str, points: &[(f64, f64)]) {
if !self.path.is_empty() {
self.path.push(' ');
}
self.path.push_str(operator);
for (x, y) in points {
self.path.push_str(&format!(" {} {}", fmt(*x), fmt(*y)));
}
}
}
impl skrifa::outline::OutlinePen for SvgGlyphOutline {
fn move_to(&mut self, x: f32, y: f32) {
let point = self.point(x, y);
self.command("M", &[point]);
}
fn line_to(&mut self, x: f32, y: f32) {
let point = self.point(x, y);
self.command("L", &[point]);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
let control = self.point(x1, y1);
let point = self.point(x, y);
self.command("Q", &[control, point]);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
let first = self.point(x1, y1);
let second = self.point(x2, y2);
let point = self.point(x, y);
self.command("C", &[first, second, point]);
}
fn close(&mut self) {
self.command("Z", &[]);
}
}
fn append_stet_path(
output: &mut String,
path: &stet_fonts::PsPath,
matrix: [f64; 6],
x_offset: f64,
) {
let transform = |x: f64, y: f64| {
(
x_offset + matrix[0] * x + matrix[2] * y + matrix[4],
matrix[1] * x + matrix[3] * y + matrix[5],
)
};
for segment in &path.segments {
if !output.is_empty() {
output.push(' ');
}
match *segment {
stet_fonts::PathSegment::MoveTo(x, y) => {
let point = transform(x, y);
output.push_str(&format!("M {} {}", fmt(point.0), fmt(point.1)));
}
stet_fonts::PathSegment::LineTo(x, y) => {
let point = transform(x, y);
output.push_str(&format!("L {} {}", fmt(point.0), fmt(point.1)));
}
stet_fonts::PathSegment::CurveTo {
x1,
y1,
x2,
y2,
x3,
y3,
} => {
let first = transform(x1, y1);
let second = transform(x2, y2);
let third = transform(x3, y3);
output.push_str(&format!(
"C {} {} {} {} {} {}",
fmt(first.0),
fmt(first.1),
fmt(second.0),
fmt(second.1),
fmt(third.0),
fmt(third.1)
));
}
stet_fonts::PathSegment::ClosePath => output.push('Z'),
}
}
}
struct Interpreter<'a, 'page> {
document: &'a Document,
page: &'page mut Page,
page_matrix: Matrix,
page_id: ObjectId,
fonts: HashMap<Vec<u8>, FontDecoder>,
resources: Vec<Dictionary>,
state: GraphicsState,
content_base_ctm: Matrix,
stack: Vec<SavedGraphicsState>,
path: PathBuilder,
pending_clip_rule: Option<String>,
text: TextState,
node_counter: usize,
clip_counter: usize,
mask_counter: usize,
compatibility_depth: usize,
visited_forms: HashSet<ObjectId>,
visited_patterns: HashSet<ObjectId>,
mesh_output_count: Rc<Cell<usize>>,
hidden_ocgs: HashSet<ObjectId>,
marked_content_hidden: bool,
marked_content_stack: Vec<bool>,
content_limit: usize,
outline_embedded_pdf_text: bool,
}
impl Interpreter<'_, '_> {
fn interpret(&mut self, operations: &[Operation], depth: usize) -> Result<()> {
if depth > MAX_FORM_DEPTH {
return Err(Error::LimitExceeded(format!(
"PDF Form XObject recursion exceeds {MAX_FORM_DEPTH}"
)));
}
for operation in operations {
if self.node_counter >= MAX_PDF_PAGE_NODES - MAX_PAGE_MESH_TRIANGLES {
self.page.warn(
"PDF page reached its bounded content-node budget; remaining content operators were omitted to reserve mesh-rendering capacity",
);
break;
}
self.operator(operation, depth)?;
}
Ok(())
}
fn operator(&mut self, operation: &Operation, depth: usize) -> Result<()> {
let operands = &operation.operands;
match operation.operator.as_str() {
"BX" => self.compatibility_depth = self.compatibility_depth.saturating_add(1),
"EX" => self.compatibility_depth = self.compatibility_depth.saturating_sub(1),
"q" => {
if self.stack.len() >= MAX_GRAPHICS_STACK {
return Err(Error::LimitExceeded(format!(
"PDF graphics-state stack exceeds {MAX_GRAPHICS_STACK}"
)));
}
self.stack.push(SavedGraphicsState {
graphics: self.state.clone(),
text: self.text.saved_parameters(),
});
}
"Q" => {
if let Some(saved) = self.stack.pop() {
self.state = saved.graphics;
self.text.restore_parameters(saved.text);
} else {
self.page.warn("PDF Q operator has no matching q");
}
}
"cm" => {
if let Some(matrix) = matrix_operands(operands) {
let previous = self.state.ctm;
let next = compose(previous, matrix);
let fill = self.state.fill.clone();
let stroke = self.state.stroke.clone();
self.state.fill = self.rebase_pattern_paint(&fill, previous, next);
self.state.stroke = self.rebase_pattern_paint(&stroke, previous, next);
self.state.ctm = next;
}
}
"w" => self.state.line_width = number(operands.first()).unwrap_or(1.0).max(0.0),
"J" => {
self.state.line_cap = match integer(operands.first()).unwrap_or(0) {
1 => LineCap::Round,
2 => LineCap::Square,
_ => LineCap::Butt,
};
}
"j" => {
self.state.line_join = match integer(operands.first()).unwrap_or(0) {
1 => LineJoin::Round,
2 => LineJoin::Bevel,
_ => LineJoin::Miter,
};
}
"M" => self.state.miter_limit = number(operands.first()).unwrap_or(10.0).max(1.0),
"d" => self.apply_dash(operands),
"m" => self.path.command("M", &numbers(operands, 2)),
"l" => self.path.command("L", &numbers(operands, 2)),
"c" => self.path.command("C", &numbers(operands, 6)),
"v" => {
let values = numbers(operands, 4);
if values.len() == 4
&& let Some((current_x, current_y)) = self.path.current_point
{
self.path.command(
"C",
&[
current_x, current_y, values[0], values[1], values[2], values[3],
],
);
}
}
"y" => {
let values = numbers(operands, 4);
if values.len() == 4 {
self.path.command(
"C",
&[
values[0], values[1], values[2], values[3], values[2], values[3],
],
);
}
}
"h" => self.path.command("Z", &[]),
"re" => {
let values = numbers(operands, 4);
if values.len() == 4 {
let [x, y, width, height] = [values[0], values[1], values[2], values[3]];
self.path.add_rect(x, y, width, height);
}
}
"W" => self.pending_clip_rule = Some("nonzero".into()),
"W*" => self.pending_clip_rule = Some("evenodd".into()),
"S" => self.paint_path(false, true, false, "nonzero"),
"s" => self.paint_path(false, true, true, "nonzero"),
"f" | "F" => self.paint_path(true, false, false, "nonzero"),
"f*" => self.paint_path(true, false, false, "evenodd"),
"B" => self.paint_path(true, true, false, "nonzero"),
"B*" => self.paint_path(true, true, false, "evenodd"),
"b" => self.paint_path(true, true, true, "nonzero"),
"b*" => self.paint_path(true, true, true, "evenodd"),
"n" => self.finish_clip_and_clear(),
"g" => {
self.state.fill_color_space = Object::Name(b"DeviceGray".to_vec());
self.state.fill_pattern_clip = None;
self.state.fill = gray_paint(
number(operands.first()).unwrap_or(0.0),
self.state.fill_alpha,
)
}
"G" => {
self.state.stroke_color_space = Object::Name(b"DeviceGray".to_vec());
self.state.stroke_pattern_clip = None;
self.state.stroke = gray_paint(
number(operands.first()).unwrap_or(0.0),
self.state.stroke_alpha,
)
}
"rg" => {
self.state.fill_color_space = Object::Name(b"DeviceRGB".to_vec());
self.state.fill_pattern_clip = None;
self.state.fill = rgb_paint(&numbers(operands, 3), self.state.fill_alpha);
}
"RG" => {
self.state.stroke_color_space = Object::Name(b"DeviceRGB".to_vec());
self.state.stroke_pattern_clip = None;
self.state.stroke = rgb_paint(&numbers(operands, 3), self.state.stroke_alpha);
}
"k" => {
self.state.fill_color_space = Object::Name(b"DeviceCMYK".to_vec());
self.state.fill_pattern_clip = None;
self.state.fill = cmyk_paint(&numbers(operands, 4), self.state.fill_alpha);
}
"K" => {
self.state.stroke_color_space = Object::Name(b"DeviceCMYK".to_vec());
self.state.stroke_pattern_clip = None;
self.state.stroke = cmyk_paint(&numbers(operands, 4), self.state.stroke_alpha);
}
"cs" => {
if let Some(name) = name(operands.first()) {
self.state.fill_color_space = self.resolve_color_space_name(name);
self.state.fill_pattern_clip = None;
}
}
"CS" => {
if let Some(name) = name(operands.first()) {
self.state.stroke_color_space = self.resolve_color_space_name(name);
self.state.stroke_pattern_clip = None;
}
}
"sc" | "scn" => self.set_current_color(false, operands),
"SC" | "SCN" => self.set_current_color(true, operands),
"BT" => {
self.text.text_matrix = IDENTITY;
self.text.line_matrix = IDENTITY;
self.text.pending_clips.clear();
}
"Tf" => {
self.text.font_name = name(operands.first()).unwrap_or_default().to_vec();
self.text.font_size = number(operands.get(1)).unwrap_or(12.0).abs();
}
"Tm" => {
if let Some(matrix) = matrix_operands(operands) {
self.text.text_matrix = matrix;
self.text.line_matrix = matrix;
}
}
"Td" => self.move_text(
number(operands.first()).unwrap_or(0.0),
number(operands.get(1)).unwrap_or(0.0),
),
"TD" => {
let y = number(operands.get(1)).unwrap_or(0.0);
self.text.leading = -y;
self.move_text(number(operands.first()).unwrap_or(0.0), y);
}
"T*" => self.move_text(0.0, -self.text.leading),
"Tc" => self.text.character_spacing = number(operands.first()).unwrap_or(0.0),
"Tw" => self.text.word_spacing = number(operands.first()).unwrap_or(0.0),
"Tz" => self.text.horizontal_scale = number(operands.first()).unwrap_or(100.0) / 100.0,
"TL" => self.text.leading = number(operands.first()).unwrap_or(0.0),
"Ts" => self.text.rise = number(operands.first()).unwrap_or(0.0),
"Tr" => self.text.rendering_mode = integer(operands.first()).unwrap_or(0),
"Tj" => {
if let Some(bytes) = string_bytes(operands.first()) {
self.show_text(bytes);
}
}
"TJ" => self.show_text_array(operands.first()),
"'" => {
self.move_text(0.0, -self.text.leading);
if let Some(bytes) = string_bytes(operands.first()) {
self.show_text(bytes);
}
}
"\"" => {
self.text.word_spacing = number(operands.first()).unwrap_or(0.0);
self.text.character_spacing = number(operands.get(1)).unwrap_or(0.0);
self.move_text(0.0, -self.text.leading);
if let Some(bytes) = string_bytes(operands.get(2)) {
self.show_text(bytes);
}
}
"Do" => {
if let Some(name) = name(operands.first()) {
self.draw_xobject(name, depth)?;
}
}
"gs" => {
if let Some(name) = name(operands.first()) {
self.apply_ext_gstate(name)?;
}
}
"sh" => {
if let Some(name) = name(operands.first()) {
self.draw_shading(name)?;
}
}
"BI" => {
if let Some(Object::Stream(stream)) = operands.first() {
let inline_image = normalize_inline_image(stream.clone());
self.draw_image(&inline_image, b"inline")?;
} else {
self.page.warn(
"PDF filtered inline image was skipped by the syntax decoder and needs native filter recovery",
);
}
}
"ID" | "EI" => {}
"ET" => self.apply_pending_text_clips(),
"T_s" | "T_w" | "T_L" | "T_c" => {}
"BDC" => {
let tag = name(operands.first()).unwrap_or_default();
let hidden_here = if tag == b"OC" {
match operands.get(1) {
Some(Object::Name(property_name)) => self
.lookup_named_resource_with_id(b"Properties", property_name)
.is_some_and(|(object_id, object)| {
!self.is_oc_visible(object_id, &object)
}),
Some(dictionary_operand @ Object::Dictionary(_)) => {
!self.is_oc_visible(None, dictionary_operand)
}
_ => false,
}
} else {
false
};
let now_hidden = self.marked_content_hidden || hidden_here;
self.marked_content_stack.push(now_hidden);
self.marked_content_hidden = now_hidden;
}
"BMC" => {
self.marked_content_stack.push(self.marked_content_hidden);
}
"EMC" => {
self.marked_content_stack.pop();
self.marked_content_hidden =
self.marked_content_stack.last().copied().unwrap_or(false);
}
unsupported
if self.compatibility_depth == 0
&& !matches!(unsupported, "ri" | "i" | "d0" | "d1" | "MP" | "DP") =>
{
self.page
.warn(format!("PDF operator {unsupported} is not yet implemented"));
}
_ => {}
}
Ok(())
}
fn apply_dash(&mut self, operands: &[Object]) {
self.state.dash_array = operands
.first()
.and_then(|object| object.as_array().ok())
.map(|values| {
values
.iter()
.filter_map(|value| number(Some(value)))
.collect()
})
.unwrap_or_default();
self.state.dash_offset = number(operands.get(1)).unwrap_or(0.0);
}
fn resolve_color_space_name(&self, name: &[u8]) -> Object {
match name {
b"DeviceGray" | b"G" | b"DeviceRGB" | b"RGB" | b"DeviceCMYK" | b"CMYK" | b"Pattern" => {
Object::Name(name.to_vec())
}
_ => self
.lookup_named_resource(b"ColorSpace", name)
.unwrap_or_else(|| Object::Name(name.to_vec())),
}
}
fn resolve_color_space_object(&self, object: &Object) -> Object {
let resolved = self
.document
.dereference(object)
.ok()
.map(|(_, value)| value)
.unwrap_or(object);
if let Object::Name(name) = resolved {
self.resolve_color_space_name(name)
} else {
resolved.clone()
}
}
fn set_current_color(&mut self, stroke: bool, operands: &[Object]) {
let color_space = if stroke {
self.state.stroke_color_space.clone()
} else {
self.state.fill_color_space.clone()
};
if pdf_color_space_is_pattern(self.document, &color_space) {
let Some(pattern_name) = operands.iter().rev().find_map(|value| value.as_name().ok())
else {
self.page
.warn("PDF Pattern color selection has no pattern name");
return;
};
let opacity = if stroke {
self.state.stroke_alpha
} else {
self.state.fill_alpha
};
let Some((paint, pattern_clip)) =
self.pattern_paint(pattern_name, &color_space, operands, opacity)
else {
return;
};
if stroke {
self.state.stroke = paint;
self.state.stroke_pattern_clip = pattern_clip;
} else {
self.state.fill = paint;
self.state.fill_pattern_clip = pattern_clip;
}
return;
}
let components = numbers(operands, usize::MAX);
let opacity = if stroke {
self.state.stroke_alpha
} else {
self.state.fill_alpha
};
let paint = Paint::Solid {
color: components_to_color(self.document, Some(&color_space), &components),
opacity,
};
if stroke {
self.state.stroke = paint;
self.state.stroke_pattern_clip = None;
} else {
self.state.fill = paint;
self.state.fill_pattern_clip = None;
}
}
fn rebase_pattern_paint(&mut self, paint: &Paint, previous: Matrix, next: Matrix) -> Paint {
let Paint::PatternRef { id, opacity } = paint else {
return paint.clone();
};
let Some(inverse) = inverse_matrix(next) else {
return paint.clone();
};
let Some(mut definition) = self
.page
.patterns
.iter()
.find(|pattern| pattern.id == *id)
.cloned()
else {
return paint.clone();
};
definition.transform = compose(inverse, compose(previous, definition.transform));
definition.id = pattern_variant_id(id, definition.transform);
let variant_id = definition.id.clone();
if !self
.page
.patterns
.iter()
.any(|pattern| pattern.id == variant_id)
{
self.page.patterns.push(definition);
}
Paint::PatternRef {
id: variant_id,
opacity: *opacity,
}
}
fn pattern_paint(
&mut self,
pattern_name: &[u8],
pattern_color_space: &Object,
operands: &[Object],
opacity: f64,
) -> Option<(Paint, Option<PatternClip>)> {
let Some(pattern) = self.lookup_named_resource(b"Pattern", pattern_name) else {
self.page.warn(format!(
"PDF pattern {} was not found",
String::from_utf8_lossy(pattern_name)
));
return None;
};
let pattern_dictionary = match &pattern {
Object::Dictionary(dictionary) => dictionary,
Object::Stream(stream) => &stream.dict,
_ => {
self.page.warn("PDF Pattern resource is not a dictionary");
return None;
}
};
let pattern_type = pattern_dictionary
.get(b"PatternType")
.and_then(Object::as_i64)
.unwrap_or(0);
if pattern_type == 1 {
return self.tiling_pattern_paint(pattern_name, pattern_color_space, operands, opacity);
}
if pattern_type != 2 {
self.page.warn(format!(
"PDF pattern {} has unsupported PatternType {pattern_type}",
String::from_utf8_lossy(pattern_name)
));
return None;
}
let shading_object = pattern_dictionary.get(b"Shading").ok()?;
let (_, shading_object) = self.document.dereference(shading_object).ok()?;
let shading_dictionary = match shading_object {
Object::Dictionary(dictionary) => dictionary,
Object::Stream(stream) => &stream.dict,
_ => {
self.page
.warn("PDF shading pattern has no shading dictionary");
return None;
}
};
let shading_type = shading_dictionary
.get(b"ShadingType")
.and_then(Object::as_i64)
.unwrap_or(0);
if shading_type == 1 {
return self.function_shading_pattern_paint(
pattern_name,
pattern_dictionary,
shading_dictionary,
opacity,
);
}
if matches!(shading_type, 4..=7) {
return self.mesh_shading_pattern_paint(
pattern_name,
pattern_dictionary,
shading_object,
shading_dictionary,
shading_type,
opacity,
);
}
if !matches!(shading_type, 2 | 3) {
self.page.warn(format!(
"PDF shading PatternType 2 uses unsupported shading type {shading_type}"
));
return None;
}
let coordinates = shading_dictionary
.get(b"Coords")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 6))
.unwrap_or_default();
let required = if shading_type == 2 { 4 } else { 6 };
if coordinates.len() != required {
self.page
.warn("PDF shading PatternType 2 has invalid /Coords");
return None;
}
let function = shading_dictionary.get(b"Function").ok()?;
let domain = shading_dictionary
.get(b"Domain")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 2))
.filter(|values| values.len() == 2)
.unwrap_or_else(|| vec![0.0, 1.0]);
let color_space = shading_dictionary.get(b"ColorSpace").ok();
let extend = shading_dictionary
.get(b"Extend")
.and_then(Object::as_array)
.ok()
.map(|values| {
[
values
.first()
.and_then(|value| value.as_bool().ok())
.unwrap_or(false),
values
.get(1)
.and_then(|value| value.as_bool().ok())
.unwrap_or(false),
]
})
.unwrap_or([false, false]);
let opacity = opacity.clamp(0.0, 1.0);
let background_components = shading_dictionary
.get(b"Background")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX));
let background_rgb = background_components
.as_ref()
.and_then(|components| components_to_rgb(self.document, color_space, components, 0));
let background = background_components
.as_ref()
.map(|values| components_to_color(self.document, color_space, values));
let layered_opacity = if background_rgb.is_some() {
opacity * (2.0 - opacity)
} else {
opacity
};
let mut stops = Vec::with_capacity(35);
for index in 0..=32 {
let offset = f64::from(index) / 32.0;
let input = domain[0] + (domain[1] - domain[0]) * offset;
let Some(components) = evaluate_pdf_function(self.document, function, input, 0) else {
self.page
.warn("PDF shading pattern uses an unsupported function");
return None;
};
let color = if let Some(background_rgb) = background_rgb {
let source = components_to_rgb(self.document, color_space, &components, 0)
.unwrap_or_else(|| default_rgb_components(&components));
let denominator = (2.0 - opacity).max(1e-12);
let layered = std::array::from_fn(|channel| {
(source[channel] + (1.0 - opacity) * background_rgb[channel]) / denominator
});
let [red, green, blue] = rgb_to_bytes(layered);
format!("#{red:02X}{green:02X}{blue:02X}")
} else {
components_to_color(self.document, color_space, &components)
};
stops.push(GradientStop {
offset,
color,
opacity: layered_opacity,
});
}
apply_gradient_extension_with_background(
&mut stops,
extend,
background.as_deref(),
opacity,
);
let pattern_matrix = pattern_dictionary
.get(b"Matrix")
.and_then(Object::as_array)
.ok()
.and_then(|values| matrix_operands(values))
.unwrap_or(IDENTITY);
let pattern_matrix = inverse_matrix(self.state.ctm)
.map(|inverse| compose(inverse, compose(self.content_base_ctm, pattern_matrix)))
.unwrap_or(pattern_matrix);
let pattern_clip = shading_dictionary
.get(b"BBox")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 4))
.filter(|values| values.len() == 4)
.map(|bbox| PatternClip {
bbox: [bbox[0], bbox[1], bbox[2], bbox[3]],
matrix: pattern_matrix,
});
if shading_type == 2 {
let (x1, y1, x2, y2) = transformed_axial_axis(
pattern_matrix,
coordinates[0],
coordinates[1],
coordinates[2],
coordinates[3],
)?;
return Some((
Paint::LinearGradient(Box::new(LinearGradient {
x1,
y1,
x2,
y2,
stops,
})),
pattern_clip,
));
}
let mut first_radius = coordinates[2].max(0.0);
let mut second_radius = coordinates[5].max(0.0);
let mut first_x = coordinates[0];
let mut first_y = coordinates[1];
let mut second_x = coordinates[3];
let mut second_y = coordinates[4];
let center_distance = (second_x - first_x).hypot(second_y - first_y);
if center_distance + first_radius.min(second_radius)
> first_radius.max(second_radius) + 1e-9
{
self.page
.warn("PDF noncontained radial shading pattern requires field tessellation");
return None;
}
if first_radius > second_radius {
std::mem::swap(&mut first_radius, &mut second_radius);
std::mem::swap(&mut first_x, &mut second_x);
std::mem::swap(&mut first_y, &mut second_y);
for stop in &mut stops {
stop.offset = 1.0 - stop.offset;
}
stops.reverse();
}
Some((
Paint::RadialGradient(Box::new(RadialGradient {
fx: first_x,
fy: first_y,
fr: first_radius,
cx: second_x,
cy: second_y,
radius: second_radius.max(1e-9),
transform: pattern_matrix,
stops,
})),
pattern_clip,
))
}
fn function_shading_pattern_paint(
&mut self,
pattern_name: &[u8],
pattern_dictionary: &Dictionary,
shading_dictionary: &Dictionary,
opacity: f64,
) -> Option<(Paint, Option<PatternClip>)> {
let function = shading_dictionary.get(b"Function").ok()?;
let domain = shading_dictionary
.get(b"Domain")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 4))
.filter(|values| values.len() == 4)
.unwrap_or_else(|| vec![0.0, 1.0, 0.0, 1.0]);
let color_space = shading_dictionary.get(b"ColorSpace").ok();
let pattern_matrix = pattern_dictionary
.get(b"Matrix")
.and_then(Object::as_array)
.ok()
.and_then(|values| matrix_operands(values))
.unwrap_or(IDENTITY);
let pattern_matrix = inverse_matrix(self.state.ctm)
.map(|inverse| compose(inverse, compose(self.content_base_ctm, pattern_matrix)))
.unwrap_or(pattern_matrix);
let transform_key = pattern_matrix
.iter()
.map(|value| format!("{:x}", value.to_bits()))
.collect::<Vec<_>>()
.join("-");
let id = format!(
"pdf-shading-pattern-{}-{transform_key}",
pattern_name
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
);
let opacity = opacity.clamp(0.0, 1.0);
let pattern_clip = Some(PatternClip {
bbox: [domain[0], domain[2], domain[1], domain[3]],
matrix: pattern_matrix,
});
if self.page.patterns.iter().any(|pattern| pattern.id == id) {
return Some((Paint::PatternRef { id, opacity }, pattern_clip));
}
let Some((nodes, truncated)) = build_function_shading_cell_nodes(
self.document,
function,
&domain,
IDENTITY,
color_space,
self.state.fill_alpha,
&self.state.blend_mode,
self.state.mask_id.as_deref().unwrap_or_default(),
self.state.alpha_is_shape,
None,
self.page.number,
"pdf-function-pattern-cell",
&String::from_utf8_lossy(pattern_name),
) else {
self.page.warn(format!(
"PDF shading pattern {} uses an unsupported function",
String::from_utf8_lossy(pattern_name)
));
return None;
};
if truncated {
self.page.warn(format!(
"PDF shading pattern {} is too fine-grained to render exactly; showing a lower-resolution approximation",
String::from_utf8_lossy(pattern_name)
));
}
let width = (domain[1] - domain[0]).abs().max(1e-6);
let height = (domain[3] - domain[2]).abs().max(1e-6);
self.page.patterns.push(TilingPatternDefinition {
id: id.clone(),
x: domain[0],
y: domain[2],
width: width + pattern_tile_margin(width),
height: height + pattern_tile_margin(height),
transform: pattern_matrix,
nodes,
});
Some((Paint::PatternRef { id, opacity }, pattern_clip))
}
fn mesh_shading_pattern_paint(
&mut self,
pattern_name: &[u8],
pattern_dictionary: &Dictionary,
shading_object: &Object,
shading_dictionary: &Dictionary,
shading_type: i64,
opacity: f64,
) -> Option<(Paint, Option<PatternClip>)> {
let pattern_matrix = pattern_dictionary
.get(b"Matrix")
.and_then(Object::as_array)
.ok()
.and_then(|values| matrix_operands(values))
.unwrap_or(IDENTITY);
let pattern_matrix = inverse_matrix(self.state.ctm)
.map(|inverse| compose(inverse, compose(self.content_base_ctm, pattern_matrix)))
.unwrap_or(pattern_matrix);
let transform_key = pattern_matrix
.iter()
.map(|value| format!("{:x}", value.to_bits()))
.collect::<Vec<_>>()
.join("-");
let id = format!(
"pdf-shading-pattern-{}-{transform_key}",
pattern_name
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
);
let opacity = opacity.clamp(0.0, 1.0);
let parsed = parse_mesh_shading_triangles(
self.document,
shading_object,
shading_dictionary,
shading_type,
self.content_limit,
self.mesh_output_count.get(),
pattern_name,
self.page,
);
let (triangles, subdivisions, color_space) = match parsed {
Ok(Some(parsed)) => parsed,
Ok(None) => return None,
Err(error) => {
self.page.warn(format!(
"PDF shading pattern {} was skipped: {error}",
String::from_utf8_lossy(pattern_name)
));
return None;
}
};
let mut min_x = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut min_y = f64::INFINITY;
let mut max_y = f64::NEG_INFINITY;
for triangle in &triangles {
for vertex in &triangle.0 {
min_x = min_x.min(vertex.x);
max_x = max_x.max(vertex.x);
min_y = min_y.min(vertex.y);
max_y = max_y.max(vertex.y);
}
}
if !min_x.is_finite() || !max_x.is_finite() || !min_y.is_finite() || !max_y.is_finite() {
return None;
}
let nodes = build_mesh_triangle_nodes(
self.document,
&triangles,
subdivisions,
color_space.as_ref(),
IDENTITY,
self.state.fill_alpha,
&self.state.blend_mode,
self.state.mask_id.as_deref().unwrap_or_default(),
self.state.alpha_is_shape,
None,
self.page.number,
"pdf-mesh-pattern-cell",
&String::from_utf8_lossy(pattern_name),
&self.mesh_output_count,
);
if nodes.is_empty() {
return None;
}
let pattern_clip = Some(PatternClip {
bbox: [min_x, min_y, max_x, max_y],
matrix: pattern_matrix,
});
if !self.page.patterns.iter().any(|pattern| pattern.id == id) {
let width = (max_x - min_x).max(1e-6);
let height = (max_y - min_y).max(1e-6);
self.page.patterns.push(TilingPatternDefinition {
id: id.clone(),
x: min_x,
y: min_y,
width: width + pattern_tile_margin(width),
height: height + pattern_tile_margin(height),
transform: pattern_matrix,
nodes,
});
}
Some((Paint::PatternRef { id, opacity }, pattern_clip))
}
fn tiling_pattern_paint(
&mut self,
pattern_name: &[u8],
pattern_color_space: &Object,
operands: &[Object],
opacity: f64,
) -> Option<(Paint, Option<PatternClip>)> {
let Some((object_id, pattern)) =
self.lookup_named_resource_with_id(b"Pattern", pattern_name)
else {
self.page.warn(format!(
"PDF tiling pattern {} was not found",
String::from_utf8_lossy(pattern_name)
));
return None;
};
let Object::Stream(stream) = pattern else {
self.page
.warn("PDF tiling PatternType 1 has no content stream");
return None;
};
let bbox = stream
.dict
.get(b"BBox")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 4))
.filter(|values| values.len() == 4)?;
let x_step = stream
.dict
.get(b"XStep")
.ok()
.and_then(|value| number(Some(value)))?;
let y_step = stream
.dict
.get(b"YStep")
.ok()
.and_then(|value| number(Some(value)))?;
if x_step.abs() <= 1e-12 || y_step.abs() <= 1e-12 {
self.page.warn("PDF tiling pattern has a zero XStep/YStep");
return None;
}
let pattern_matrix = stream
.dict
.get(b"Matrix")
.and_then(Object::as_array)
.ok()
.and_then(|values| matrix_operands(values))
.unwrap_or(IDENTITY);
let pattern_matrix = inverse_matrix(self.state.ctm)
.map(|inverse| compose(inverse, compose(self.content_base_ctm, pattern_matrix)))
.unwrap_or(pattern_matrix);
let resources = stream
.dict
.get_deref(b"Resources", self.document)
.and_then(Object::as_dict)
.ok()
.cloned();
let paint_type = stream
.dict
.get(b"PaintType")
.and_then(Object::as_i64)
.unwrap_or(1);
let base_color_space = (paint_type == 2)
.then(|| match pattern_color_space {
Object::Array(values)
if values.first().and_then(|value| value.as_name().ok())
== Some(b"Pattern") =>
{
values.get(1)
}
_ => None,
})
.flatten();
let components = numbers(operands, usize::MAX);
let base_color = (paint_type == 2)
.then(|| components_to_color(self.document, base_color_space, &components));
let resource_key = object_id.map_or_else(
|| {
pattern_name
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
},
|object_id| format!("{}-{}", object_id.0, object_id.1),
);
let transform_key = pattern_matrix
.iter()
.map(|value| format!("{:x}", value.to_bits()))
.collect::<Vec<_>>()
.join("-");
let resource_key = format!("{resource_key}-{transform_key}");
let id = base_color.as_ref().map_or_else(
|| format!("pdf-pattern-{resource_key}"),
|color| {
format!(
"pdf-pattern-{resource_key}-{}",
color.trim_start_matches('#')
)
},
);
if self.page.patterns.iter().any(|pattern| pattern.id == id) {
return Some((
Paint::PatternRef {
id,
opacity: opacity.clamp(0.0, 1.0),
},
None,
));
}
if let Some(object_id) = object_id
&& !self.visited_patterns.insert(object_id)
{
self.page.warn("cyclic PDF tiling pattern was skipped");
return None;
}
let mut pattern_page = Page::new(
self.page.number,
x_step.abs().max(1.0),
y_step.abs().max(1.0),
"pdf-pattern",
);
let mut pattern_fonts = self.fonts.clone();
pattern_fonts.extend(build_resource_font_decoders(
self.document,
resources.as_ref(),
self.content_limit,
&mut pattern_page,
));
let mut pattern_state = GraphicsState::default();
if let Some(base_color) = base_color {
let paint = Paint::Solid {
color: base_color,
opacity: 1.0,
};
pattern_state.fill = paint.clone();
pattern_state.stroke = paint;
pattern_state.fill_color_space = base_color_space
.cloned()
.unwrap_or_else(|| Object::Name(b"DeviceGray".to_vec()));
pattern_state.stroke_color_space = pattern_state.fill_color_space.clone();
}
let content = match stream.decompressed_content_with_limit(self.content_limit) {
Ok(content) => content,
Err(error) => {
if let Some(object_id) = object_id {
self.visited_patterns.remove(&object_id);
}
self.page
.warn(format!("PDF tiling pattern could not be decoded: {error}"));
return None;
}
};
let decoded =
match decode_content_operations(&content, self.content_limit, &mut pattern_page) {
Ok(decoded) => decoded,
Err(error) => {
if let Some(object_id) = object_id {
self.visited_patterns.remove(&object_id);
}
self.page
.warn(format!("PDF tiling pattern content is invalid: {error}"));
return None;
}
};
let mut pattern_interpreter = Interpreter {
document: self.document,
page: &mut pattern_page,
page_matrix: IDENTITY,
page_id: self.page_id,
fonts: pattern_fonts,
resources: resources.into_iter().collect(),
state: pattern_state,
content_base_ctm: IDENTITY,
stack: Vec::new(),
path: PathBuilder::default(),
pending_clip_rule: None,
text: TextState::default(),
node_counter: self.node_counter,
clip_counter: self.clip_counter,
mask_counter: self.mask_counter,
compatibility_depth: self.compatibility_depth,
visited_forms: self.visited_forms.clone(),
visited_patterns: self.visited_patterns.clone(),
mesh_output_count: Rc::clone(&self.mesh_output_count),
hidden_ocgs: self.hidden_ocgs.clone(),
marked_content_hidden: false,
marked_content_stack: Vec::new(),
content_limit: self.content_limit,
outline_embedded_pdf_text: self.outline_embedded_pdf_text,
};
let interpretation = pattern_interpreter.interpret(&decoded, 1);
self.node_counter = pattern_interpreter.node_counter;
self.clip_counter = pattern_interpreter.clip_counter;
self.mask_counter = pattern_interpreter.mask_counter;
drop(pattern_interpreter);
if let Some(object_id) = object_id {
self.visited_patterns.remove(&object_id);
}
if let Err(error) = interpretation {
self.page.warn(format!(
"PDF tiling pattern could not be interpreted: {error}"
));
return None;
}
self.clip_counter += 1;
let content_clip_id = format!(
"pdf-pattern-content-clip-{}-{}",
self.page.number, self.clip_counter
);
pattern_page.clips.push(crate::ir::ClipPath {
id: content_clip_id.clone(),
d: rectangle_path(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]),
transform: IDENTITY,
fill_rule: "nonzero".into(),
parent_id: None,
additional_paths: Vec::new(),
});
let mut nodes = pattern_page.nodes;
if !nodes.is_empty() {
nodes = vec![Node::Group {
id: format!("{content_clip_id}-group"),
nodes,
transform: IDENTITY,
opacity: 1.0,
clip_id: Some(content_clip_id),
meta: SourceMeta {
kind: "tiling-pattern-cell".into(),
..SourceMeta::default()
},
}];
}
self.page.clips.extend(pattern_page.clips);
self.page.masks.extend(pattern_page.masks);
self.page.patterns.extend(pattern_page.patterns);
for warning in pattern_page.warnings {
self.page.warn(format!("tiling pattern: {warning}"));
}
self.page.patterns.push(TilingPatternDefinition {
id: id.clone(),
x: bbox[0],
y: bbox[1],
width: x_step.abs(),
height: y_step.abs(),
transform: pattern_matrix,
nodes,
});
Some((
Paint::PatternRef {
id,
opacity: opacity.clamp(0.0, 1.0),
},
None,
))
}
fn selected_pattern_clip(&mut self, fill: bool, stroke: bool) -> Option<PatternClip> {
let fill_clip = fill.then(|| self.state.fill_pattern_clip.clone()).flatten();
let stroke_clip = stroke
.then(|| self.state.stroke_pattern_clip.clone())
.flatten();
if fill_clip.is_some() && stroke_clip.is_some() && fill_clip != stroke_clip {
self.page.warn(
"PDF fill and stroke use different shading-pattern BBoxes; one shared clip is used",
);
}
fill_clip.or(stroke_clip)
}
fn install_pattern_bbox_clip(&mut self, pattern_clip: Option<PatternClip>) -> Option<String> {
let Some(pattern_clip) = pattern_clip else {
return self.state.clip_id.clone();
};
self.clip_counter += 1;
let id = format!(
"pdf-pattern-clip-{}-{}",
self.page.number, self.clip_counter
);
self.page.clips.push(crate::ir::ClipPath {
id: id.clone(),
d: rectangle_path(
pattern_clip.bbox[0],
pattern_clip.bbox[1],
pattern_clip.bbox[2] - pattern_clip.bbox[0],
pattern_clip.bbox[3] - pattern_clip.bbox[1],
),
transform: compose(
self.page_matrix,
compose(self.state.ctm, pattern_clip.matrix),
),
fill_rule: "nonzero".into(),
parent_id: self.state.clip_id.clone(),
additional_paths: Vec::new(),
});
Some(id)
}
fn paint_path(&mut self, fill: bool, stroke: bool, close: bool, fill_rule: &str) {
if close {
self.path.command("Z", &[]);
}
if !self.path.has_content {
return;
}
let hairline = (fill && !stroke)
.then_some(self.path.bare_rect)
.flatten()
.and_then(|[x, y, width, height]| {
let zero_width = width == 0.0;
let zero_height = height == 0.0;
if zero_width == zero_height {
return None; }
let scale = matrix_maximum_scale(self.state.ctm).max(1e-12);
let stroke_width = 1.0 / scale;
let (x2, y2) = if zero_width {
(x, y + height)
} else {
(x + width, y)
};
let line = format!("M {} {} L {} {}", fmt(x), fmt(y), fmt(x2), fmt(y2));
Some((line, stroke_width))
});
let d = self.path.take();
self.install_clip(&d);
if self.marked_content_hidden {
return;
}
let pattern_clip = self.selected_pattern_clip(fill, stroke);
let clip_id = self.install_pattern_bbox_clip(pattern_clip);
self.node_counter += 1;
let (d, fill_paint, stroke_paint, stroke_width) = match hairline {
Some((line, width)) => (line, Paint::None, self.state.fill.clone(), width),
None => (
d,
if fill {
self.state.fill.clone()
} else {
Paint::None
},
if stroke {
self.state.stroke.clone()
} else {
Paint::None
},
self.state.line_width,
),
};
self.page.nodes.push(Node::Path {
id: format!("pdf-path-{}-{}", self.page.number, self.node_counter),
d,
fill_rule: fill_rule.into(),
fill: fill_paint,
stroke: Stroke {
paint: stroke_paint,
width: stroke_width,
line_cap: self.state.line_cap,
line_join: self.state.line_join,
miter_limit: self.state.miter_limit,
dash_array: self.state.dash_array.clone(),
dash_offset: self.state.dash_offset,
},
transform: compose(self.page_matrix, self.state.ctm),
clip_id,
meta: SourceMeta {
kind: "vector".into(),
source_id: format!("page:{}:operator-path", self.page.number),
blend_mode: self.state.blend_mode.clone(),
mask_id: self.state.mask_id.clone().unwrap_or_default(),
alpha_is_shape: self.state.alpha_is_shape,
..SourceMeta::default()
},
});
}
fn finish_clip_and_clear(&mut self) {
if self.path.has_content {
let d = self.path.take();
self.install_clip(&d);
} else {
self.path.clear();
}
}
fn install_clip(&mut self, d: &str) {
let Some(rule) = self.pending_clip_rule.take() else {
return;
};
let parent_id = self.state.clip_id.clone();
self.clip_counter += 1;
let id = format!("pdf-clip-{}-{}", self.page.number, self.clip_counter);
self.page.clips.push(crate::ir::ClipPath {
id: id.clone(),
d: d.into(),
transform: compose(self.page_matrix, self.state.ctm),
fill_rule: rule,
parent_id,
additional_paths: Vec::new(),
});
self.state.clip_id = Some(id);
}
fn apply_pending_text_clips(&mut self) {
let mut pending = std::mem::take(&mut self.text.pending_clips).into_iter();
let Some(first) = pending.next() else {
return;
};
let parent_id = self.state.clip_id.clone();
self.clip_counter += 1;
let id = format!("pdf-text-clip-{}-{}", self.page.number, self.clip_counter);
self.page.clips.push(crate::ir::ClipPath {
id: id.clone(),
d: first.d,
transform: first.transform,
fill_rule: "nonzero".into(),
parent_id,
additional_paths: pending
.map(|pending| crate::ir::ClipMember {
d: pending.d,
transform: pending.transform,
fill_rule: "nonzero".into(),
})
.collect(),
});
self.state.clip_id = Some(id);
}
fn move_text(&mut self, x: f64, y: f64) {
let translation = [1.0, 0.0, 0.0, 1.0, x, y];
self.text.line_matrix = compose(self.text.line_matrix, translation);
self.text.text_matrix = self.text.line_matrix;
}
fn show_text(&mut self, bytes: &[u8]) {
let has_fill = matches!(self.text.rendering_mode, 0 | 2 | 4 | 6);
let has_stroke = matches!(self.text.rendering_mode, 1 | 2 | 5 | 6);
let pattern_clip = self.selected_pattern_clip(has_fill, has_stroke);
let paint_clip_id = self.install_pattern_bbox_clip(pattern_clip);
let decoder = self.fonts.get(&self.text.font_name);
let (text, replacement) = decoder.map_or_else(
|| (bytes.iter().map(|byte| char::from(*byte)).collect(), true),
|decoder| decoder.decode(bytes),
);
if replacement {
self.page.warn(format!(
"font {} required replacement glyphs while decoding text",
String::from_utf8_lossy(&self.text.font_name)
));
}
if self.marked_content_hidden {
self.advance_text_bytes(bytes, &text);
return;
}
if let Some(type3) = decoder.and_then(|decoder| decoder.type3.clone()) {
if self.text.rendering_mode == 3 {
self.advance_text_bytes(bytes, &text);
return;
}
if self.text.rendering_mode != 0 {
self.page.warn(format!(
"PDF Type3 text rendering mode {} is approximated by its CharProc paint operators",
self.text.rendering_mode
));
}
self.render_type3_text(bytes, &text, &type3);
self.advance_text_bytes(bytes, &text);
return;
}
self.node_counter += 1;
let mut font_family = decoder
.map(|decoder| decoder.family.clone())
.unwrap_or_else(|| "Arial, 'Hiragino Sans', 'Yu Gothic', sans-serif".into());
let stable_symbol = stable_pdf_symbol_fallback(&font_family, &text);
font_family = pdf_font_stack(&font_family);
if stable_symbol.is_some() {
font_family = "Arial, 'Hiragino Sans', 'Yu Gothic', sans-serif".into();
}
let compensation = [
self.text.horizontal_scale,
0.0,
0.0,
-1.0,
0.0,
self.text.rise,
];
let transform = compose(
self.page_matrix,
compose(self.state.ctm, compose(self.text.text_matrix, compensation)),
);
let mut outline_failure = None;
let explicit_fidelity_outline = self.text.rendering_mode != 3
&& decoder.is_some_and(|decoder| {
!decoder.requires_outline
&& should_outline_pdf_text(
decoder.requires_outline,
decoder.font_data.is_some(),
self.outline_embedded_pdf_text,
)
&& stable_symbol.is_none()
});
let outline_path = if self.text.rendering_mode == 3 {
None
} else {
decoder
.filter(|decoder| {
should_outline_pdf_text(
decoder.requires_outline,
decoder.font_data.is_some(),
self.outline_embedded_pdf_text,
) && stable_symbol.is_none()
})
.and_then(|decoder| {
match decoder.outline_path(
bytes,
self.text.character_spacing / self.text.font_size.max(1e-12),
self.text.word_spacing / self.text.font_size.max(1e-12),
) {
Ok(path) => Some(path),
Err(reason) => {
outline_failure = Some(reason);
None
}
}
})
};
if let Some(path_data) = outline_path {
if path_data.is_empty() {
self.advance_text_bytes(bytes, &text);
return;
}
let glyph_matrix = [
self.text.font_size * self.text.horizontal_scale,
0.0,
0.0,
self.text.font_size,
0.0,
self.text.rise,
];
let glyph_space_scale =
matrix_maximum_scale(compose(self.text.text_matrix, glyph_matrix)).max(1e-12);
let glyph_transform = compose(
self.page_matrix,
compose(self.state.ctm, compose(self.text.text_matrix, glyph_matrix)),
);
if matches!(self.text.rendering_mode, 4..=7) {
self.text.pending_clips.push(PendingTextClip {
d: path_data.clone(),
transform: glyph_transform,
});
}
if self.text.rendering_mode == 7 {
self.advance_text_bytes(bytes, &text);
return;
}
self.page.nodes.push(Node::Path {
id: format!(
"pdf-text-outline-{}-{}",
self.page.number, self.node_counter
),
d: path_data,
fill_rule: "nonzero".into(),
fill: if has_fill {
self.state.fill.clone()
} else {
Paint::None
},
stroke: Stroke {
paint: if has_stroke {
self.state.stroke.clone()
} else {
Paint::None
},
width: self.state.line_width / glyph_space_scale,
line_cap: self.state.line_cap,
line_join: self.state.line_join,
miter_limit: self.state.miter_limit,
dash_array: self
.state
.dash_array
.iter()
.map(|value| value / glyph_space_scale)
.collect(),
dash_offset: self.state.dash_offset / glyph_space_scale,
},
transform: glyph_transform,
clip_id: paint_clip_id.clone(),
meta: SourceMeta {
kind: "text-outline".into(),
source_id: format!(
"page:{}:font:{}",
self.page.number,
String::from_utf8_lossy(&self.text.font_name)
),
semantic_role: "text".into(),
alt_text: text.clone(),
blend_mode: self.state.blend_mode.clone(),
mask_id: self.state.mask_id.clone().unwrap_or_default(),
alpha_is_shape: self.state.alpha_is_shape,
..SourceMeta::default()
},
});
if explicit_fidelity_outline {
self.page.warn(format!(
"embedded font {} was outlined by explicit fidelity mode; verify the source font's outline and embedding rights",
decoder.map_or("unknown", |decoder| decoder.family.as_str())
));
}
self.advance_text_bytes(bytes, &text);
return;
}
if self.text.rendering_mode != 3
&& decoder.is_some_and(|decoder| decoder.requires_outline)
&& stable_symbol.is_none()
{
self.page.warn(format!(
"embedded custom font {} is emitted as editable fallback text: {}",
decoder.map_or("unknown", |decoder| decoder.family.as_str()),
outline_failure.unwrap_or("glyph outline recovery is pending")
));
}
if self.text.rendering_mode > 3 {
self.page.warn(format!(
"PDF text rendering mode {} requires an unavailable glyph-outline clip",
self.text.rendering_mode
));
if self.text.rendering_mode == 7 {
self.advance_text_bytes(bytes, &text);
return;
}
}
let text_space_scale =
matrix_maximum_scale(compose(self.text.text_matrix, compensation)).max(1e-12);
let horizontal_scale = self.text.horizontal_scale.abs().max(1e-12);
let glyph_width = decoder.map_or(bytes.len() as f64 * 0.5, |decoder| decoder.width(bytes));
let character_count =
decoder.map_or(bytes.len(), |decoder| decoder.code_count(bytes)) as f64;
let spaces = text.chars().filter(|character| *character == ' ').count() as f64;
let target_advance = glyph_width * self.text.font_size
+ (character_count * self.text.character_spacing + spaces * self.text.word_spacing)
/ horizontal_scale;
self.page.nodes.push(Node::Text {
id: format!("pdf-text-{}-{}", self.page.number, self.node_counter),
x: 0.0,
y: 0.0,
runs: vec![TextRun {
text: stable_symbol.unwrap_or(&text).to_owned(),
font_family,
font_size: self.text.font_size,
bold: decoder.is_some_and(|decoder| decoder.bold),
italic: decoder.is_some_and(|decoder| decoder.italic),
fill: if has_fill {
self.state.fill.clone()
} else {
Paint::None
},
baseline_shift: 0.0,
glyph_x_offsets: decoder
.and_then(|decoder| {
decoder.glyph_x_offsets(
bytes,
self.text.font_size,
self.text.character_spacing,
self.text.word_spacing,
self.text.horizontal_scale,
)
})
.filter(|offsets| {
offsets.len() == stable_symbol.unwrap_or(&text).chars().count()
})
.unwrap_or_default(),
target_advance: (target_advance.is_finite() && target_advance > 0.0)
.then_some(target_advance),
}],
anchor: TextAnchor::Start,
transform,
opacity: 1.0,
stroke: Stroke {
paint: if has_stroke {
self.state.stroke.clone()
} else {
Paint::None
},
width: self.state.line_width / text_space_scale,
line_cap: self.state.line_cap,
line_join: self.state.line_join,
miter_limit: self.state.miter_limit,
dash_array: self
.state
.dash_array
.iter()
.map(|value| value / text_space_scale)
.collect(),
dash_offset: self.state.dash_offset / text_space_scale,
},
clip_id: paint_clip_id,
meta: SourceMeta {
kind: "text".into(),
source_id: format!(
"page:{}:font:{}",
self.page.number,
String::from_utf8_lossy(&self.text.font_name)
),
semantic_role: "text".into(),
alt_text: text.clone(),
blend_mode: self.state.blend_mode.clone(),
mask_id: self.state.mask_id.clone().unwrap_or_default(),
alpha_is_shape: self.state.alpha_is_shape,
..SourceMeta::default()
},
});
self.advance_text_bytes(bytes, &text);
}
fn render_type3_text(&mut self, bytes: &[u8], text: &str, font: &Type3Font) {
let Some(decoder) = self.fonts.get(&self.text.font_name) else {
return;
};
let widths = decoder.widths.clone();
let default_width = decoder.default_width;
let code_width = |code: u8| {
widths
.get(&u32::from(code))
.copied()
.unwrap_or(default_width)
/ 1_000.0
};
let mut x_offset = 0.0;
let mut glyph_nodes = Vec::new();
for code in bytes {
let Some(stream) = font.glyphs.get(code) else {
if !char::from(*code).is_whitespace() {
self.page.warn(format!(
"PDF Type3 font {} has no CharProc for code {code}",
String::from_utf8_lossy(&self.text.font_name)
));
}
x_offset += code_width(*code);
continue;
};
let glyph_matrix = [
self.text.font_size * self.text.horizontal_scale,
0.0,
0.0,
self.text.font_size,
0.0,
self.text.rise,
];
let placement = [1.0, 0.0, 0.0, 1.0, x_offset, 0.0];
let total = compose(
self.page_matrix,
compose(
self.state.ctm,
compose(
self.text.text_matrix,
compose(glyph_matrix, compose(placement, font.font_matrix)),
),
),
);
let mut glyph_page = Page::new(
self.page.number,
self.page.width,
self.page.height,
"pdf-type3-glyph",
);
let mut glyph_fonts = self.fonts.clone();
glyph_fonts.extend(build_resource_font_decoders(
self.document,
font.resources.as_ref(),
self.content_limit,
&mut glyph_page,
));
let content = match stream.decompressed_content_with_limit(self.content_limit) {
Ok(content) => content,
Err(error) => {
self.page.warn(format!(
"PDF Type3 glyph stream could not be decoded: {error}"
));
continue;
}
};
let decoded =
match decode_content_operations(&content, self.content_limit, &mut glyph_page) {
Ok(decoded) => decoded,
Err(error) => {
self.page
.warn(format!("PDF Type3 glyph content is invalid: {error}"));
continue;
}
};
let mut initial_state = self.state.clone();
initial_state.ctm = IDENTITY;
initial_state.clip_id = None;
initial_state.blend_mode = "normal".into();
initial_state.mask_id = None;
let mut glyph_interpreter = Interpreter {
document: self.document,
page: &mut glyph_page,
page_matrix: total,
page_id: self.page_id,
fonts: glyph_fonts,
resources: font.resources.clone().into_iter().collect(),
state: initial_state,
content_base_ctm: IDENTITY,
stack: Vec::new(),
path: PathBuilder::default(),
pending_clip_rule: None,
text: TextState::default(),
node_counter: self.node_counter,
clip_counter: self.clip_counter,
mask_counter: self.mask_counter,
compatibility_depth: self.compatibility_depth,
visited_forms: self.visited_forms.clone(),
visited_patterns: self.visited_patterns.clone(),
mesh_output_count: Rc::clone(&self.mesh_output_count),
hidden_ocgs: self.hidden_ocgs.clone(),
marked_content_hidden: false,
marked_content_stack: Vec::new(),
content_limit: self.content_limit,
outline_embedded_pdf_text: self.outline_embedded_pdf_text,
};
let interpretation = glyph_interpreter.interpret(&decoded, 1);
self.node_counter = glyph_interpreter.node_counter;
self.clip_counter = glyph_interpreter.clip_counter;
self.mask_counter = glyph_interpreter.mask_counter;
drop(glyph_interpreter);
if let Err(error) = interpretation {
self.page
.warn(format!("PDF Type3 glyph could not be interpreted: {error}"));
}
glyph_nodes.append(&mut glyph_page.nodes);
self.page.clips.append(&mut glyph_page.clips);
self.page.masks.append(&mut glyph_page.masks);
self.page.patterns.append(&mut glyph_page.patterns);
for warning in glyph_page.warnings {
self.page.warn(format!("Type3 glyph: {warning}"));
}
let word_spacing = if *code == b' ' {
self.text.word_spacing / self.text.font_size.max(1e-12)
} else {
0.0
};
x_offset += code_width(*code)
+ self.text.character_spacing / self.text.font_size.max(1e-12)
+ word_spacing;
}
if glyph_nodes.is_empty() {
return;
}
self.node_counter += 1;
self.page.nodes.push(Node::Group {
id: format!("pdf-type3-text-{}-{}", self.page.number, self.node_counter),
nodes: glyph_nodes,
transform: IDENTITY,
opacity: 1.0,
clip_id: self.state.clip_id.clone(),
meta: SourceMeta {
kind: "type3-text".into(),
source_id: format!(
"page:{}:font:{}",
self.page.number,
String::from_utf8_lossy(&self.text.font_name)
),
semantic_role: "text".into(),
alt_text: text.into(),
blend_mode: self.state.blend_mode.clone(),
mask_id: self.state.mask_id.clone().unwrap_or_default(),
alpha_is_shape: self.state.alpha_is_shape,
..SourceMeta::default()
},
});
}
fn show_text_array(&mut self, operand: Option<&Object>) {
let Some(array) = operand.and_then(|object| object.as_array().ok()) else {
return;
};
for item in array {
if let Some(bytes) = string_bytes(Some(item)) {
self.show_text(bytes);
} else if let Some(adjustment) = number(Some(item)) {
self.advance_text(
"",
-adjustment / 1_000.0 * self.text.font_size * self.text.horizontal_scale,
);
}
}
}
fn advance_text(&mut self, text: &str, extra: f64) {
let character_count = text.chars().count() as f64;
let spaces = text.chars().filter(|character| *character == ' ').count() as f64;
let advance = character_count * self.text.font_size * 0.5 * self.text.horizontal_scale
+ character_count * self.text.character_spacing
+ spaces * self.text.word_spacing
+ extra;
self.text.text_matrix = compose(self.text.text_matrix, [1.0, 0.0, 0.0, 1.0, advance, 0.0]);
}
fn advance_text_bytes(&mut self, bytes: &[u8], text: &str) {
let decoder = self.fonts.get(&self.text.font_name);
let glyph_width = decoder.map_or(bytes.len() as f64 * 0.5, |decoder| decoder.width(bytes));
let character_count =
decoder.map_or(bytes.len(), |decoder| decoder.code_count(bytes)) as f64;
let spaces = text.chars().filter(|character| *character == ' ').count() as f64;
let advance = glyph_width * self.text.font_size * self.text.horizontal_scale
+ character_count * self.text.character_spacing
+ spaces * self.text.word_spacing;
self.text.text_matrix = compose(self.text.text_matrix, [1.0, 0.0, 0.0, 1.0, advance, 0.0]);
}
fn draw_xobject(&mut self, name: &[u8], depth: usize) -> Result<()> {
let Some((object_id, stream)) = self.lookup_xobject(name) else {
self.page.warn(format!(
"PDF XObject {} was not found",
String::from_utf8_lossy(name)
));
return Ok(());
};
if self.marked_content_hidden || !self.xobject_oc_visible(&stream.dict) {
return Ok(());
}
let subtype = stream
.dict
.get(b"Subtype")
.and_then(Object::as_name)
.unwrap_or_default();
if subtype == b"Image" {
self.draw_image(&stream, name)?;
} else if subtype == b"Form" {
if let Some(object_id) = object_id
&& !self.visited_forms.insert(object_id)
{
return Err(Error::InvalidInput(format!(
"cyclic PDF Form XObject at {} {}",
object_id.0, object_id.1
)));
}
let previous_state = self.state.clone();
let previous_content_base_ctm = self.content_base_ctm;
let form_matrix = stream
.dict
.get(b"Matrix")
.and_then(Object::as_array)
.ok()
.and_then(|matrix| matrix_operands(matrix))
.unwrap_or(IDENTITY);
self.state.ctm = compose(self.state.ctm, form_matrix);
self.content_base_ctm = self.state.ctm;
let group = stream
.dict
.get_deref(b"Group", self.document)
.and_then(Object::as_dict)
.ok()
.cloned();
let node_start = self.page.nodes.len();
if group.is_some() {
self.state.fill_alpha = 1.0;
self.state.stroke_alpha = 1.0;
self.state.blend_mode = "normal".into();
self.state.mask_id = None;
set_paint_opacity(&mut self.state.fill, 1.0);
set_paint_opacity(&mut self.state.stroke, 1.0);
}
let form_resources = stream
.dict
.get_deref(b"Resources", self.document)
.and_then(Object::as_dict)
.ok()
.cloned();
let form_fonts = build_resource_font_decoders(
self.document,
form_resources.as_ref(),
self.content_limit,
self.page,
);
let mut previous_fonts = Vec::with_capacity(form_fonts.len());
for (font_name, decoder) in form_fonts {
let previous = self.fonts.insert(font_name.clone(), decoder);
previous_fonts.push((font_name, previous));
}
let mut pushed_resources = false;
if let Some(resources) = form_resources {
self.resources.push(resources.clone());
pushed_resources = true;
}
let content = stream.decompressed_content_with_limit(self.content_limit)?;
let decoded = decode_content_operations(&content, self.content_limit, self.page)?;
let interpret_result = self.interpret(&decoded, depth + 1);
if pushed_resources {
self.resources.pop();
}
for (font_name, previous) in previous_fonts {
if let Some(previous) = previous {
self.fonts.insert(font_name, previous);
} else {
self.fonts.remove(&font_name);
}
}
self.content_base_ctm = previous_content_base_ctm;
interpret_result?;
self.state = previous_state.clone();
if let Some(group) = group {
let isolated = group.get(b"I").and_then(Object::as_bool).unwrap_or(false);
let knockout = group.get(b"K").and_then(Object::as_bool).unwrap_or(false);
let mut children = self.page.nodes.split_off(node_start);
let knockout_is_source_over_equivalent =
knockout && children_are_opaque_normal(&children);
if knockout
&& !knockout_is_source_over_equivalent
&& !apply_path_knockout_masks(self.page, &mut children, &mut self.mask_counter)
{
self.page.warn(
"PDF knockout transparency Form group contains non-path effects that cannot be masked natively",
);
}
let clip_id = stream
.dict
.get(b"BBox")
.and_then(Object::as_array)
.ok()
.map(|bbox| numbers(bbox, 4))
.filter(|bbox| bbox.len() == 4)
.map(|bbox| {
self.clip_counter += 1;
let id = format!("pdf-clip-{}-{}", self.page.number, self.clip_counter);
self.page.clips.push(crate::ir::ClipPath {
id: id.clone(),
d: rectangle_path(
bbox[0],
bbox[1],
bbox[2] - bbox[0],
bbox[3] - bbox[1],
),
transform: compose(
self.page_matrix,
compose(previous_state.ctm, form_matrix),
),
fill_rule: "nonzero".into(),
parent_id: None,
additional_paths: Vec::new(),
});
id
});
self.node_counter += 1;
self.page.nodes.push(Node::Group {
id: format!("pdf-group-{}-{}", self.page.number, self.node_counter),
nodes: children,
transform: IDENTITY,
opacity: previous_state.fill_alpha,
clip_id,
meta: SourceMeta {
kind: "transparency-group".into(),
source_id: format!(
"page:{}:xobject:{}",
self.page.number,
String::from_utf8_lossy(name)
),
blend_mode: previous_state.blend_mode.clone(),
mask_id: previous_state.mask_id.clone().unwrap_or_default(),
alpha_is_shape: previous_state.alpha_is_shape,
isolation: isolated,
..SourceMeta::default()
},
});
}
if let Some(object_id) = object_id {
self.visited_forms.remove(&object_id);
}
} else {
self.page.warn(format!(
"PDF XObject subtype {} is not supported",
String::from_utf8_lossy(subtype)
));
}
Ok(())
}
fn render_annotations(&mut self) -> Result<()> {
let Ok(page_dict) = self.document.get_dictionary(self.page_id) else {
return Ok(());
};
let Ok(annots_obj) = page_dict.get_deref(b"Annots", self.document) else {
return Ok(());
};
let Ok(annots) = annots_obj.as_array() else {
return Ok(());
};
if annots.len() > MAX_PDF_ANNOTATIONS_PER_PAGE {
self.page.warn(format!(
"PDF page has {} annotations; only the first {MAX_PDF_ANNOTATIONS_PER_PAGE} were considered",
annots.len()
));
}
for annot_ref in annots.iter().take(MAX_PDF_ANNOTATIONS_PER_PAGE) {
if self.node_counter >= MAX_PDF_PAGE_NODES {
self.page.warn(format!(
"PDF page output reached the {MAX_PDF_PAGE_NODES}-node annotation limit; remaining annotations were omitted"
));
break;
}
let Ok((_, annot_obj)) = self.document.dereference(annot_ref) else {
continue;
};
let Ok(annot_dict) = annot_obj.as_dict() else {
continue;
};
let subtype = annot_dict
.get_deref(b"Subtype", self.document)
.ok()
.and_then(|o| o.as_name().ok())
.unwrap_or_default();
if subtype == b"Popup" {
continue;
}
let flags = annot_dict
.get(b"F")
.ok()
.and_then(|o| o.as_i64().ok())
.unwrap_or(0);
if flags & 1 != 0 || flags & 2 != 0 || flags & 32 != 0 {
continue;
}
if subtype == b"Link" {
let has_appearance = annot_dict
.get_deref(b"AP", self.document)
.ok()
.and_then(|o| o.as_dict().ok())
.is_some();
if !has_appearance {
self.draw_link_annotation_border(annot_dict);
}
continue;
}
let normal_ap = annot_dict
.get_deref(b"AP", self.document)
.ok()
.and_then(|object| object.as_dict().ok())
.and_then(|appearance| appearance.get_deref(b"N", self.document).ok());
let stream = normal_ap.and_then(|normal_ap| match normal_ap {
Object::Stream(stream) => Some(stream.clone()),
Object::Dictionary(subdict) => {
let as_state = annot_dict
.get(b"AS")
.ok()
.and_then(|object| object.as_name().ok());
let state_obj = as_state
.and_then(|key| subdict.get_deref(key, self.document).ok())
.or_else(|| {
subdict.iter().find_map(|(_, value)| {
self.document
.dereference(value)
.ok()
.map(|(_, value)| value)
})
});
match state_obj {
Some(Object::Stream(stream)) => Some(stream.clone()),
_ => None,
}
}
_ => None,
});
if let Some(stream) = stream {
let rect = annot_dict
.get_deref(b"Rect", self.document)
.ok()
.and_then(|object| object.as_array().ok())
.map(|array| numbers(array, 4))
.unwrap_or_default();
if rect.len() == 4 {
self.draw_annotation_appearance(&stream, &rect)?;
}
} else if subtype == b"FreeText" {
self.draw_freetext_annotation_fallback(annot_dict)?;
} else if subtype == b"Widget" {
self.draw_widget_text_fallback(annot_dict)?;
}
}
Ok(())
}
fn draw_link_annotation_border(&mut self, annot_dict: &Dictionary) {
let Ok(rect) = annot_dict
.get_deref(b"Rect", self.document)
.and_then(Object::as_array)
else {
return;
};
let rect = numbers(rect, 4);
if rect.len() != 4 {
return;
}
let width = annot_dict
.get_deref(b"BS", self.document)
.and_then(Object::as_dict)
.ok()
.and_then(|bs| number(bs.get(b"W").ok()))
.or_else(|| {
annot_dict
.get_deref(b"Border", self.document)
.and_then(Object::as_array)
.ok()
.and_then(|border| numbers(border, 3).get(2).copied())
});
let Some(width) = width.filter(|width| *width > 0.0) else {
return;
};
let Ok(color) = annot_dict
.get_deref(b"C", self.document)
.and_then(Object::as_array)
else {
return;
};
let components = numbers(color, usize::MAX);
if components.is_empty() {
return;
}
let color_space = match components.len() {
1 => Object::Name(b"DeviceGray".to_vec()),
4 => Object::Name(b"DeviceCMYK".to_vec()),
_ => Object::Name(b"DeviceRGB".to_vec()),
};
let color = components_to_color(self.document, Some(&color_space), &components);
let x = rect[0].min(rect[2]);
let y = rect[1].min(rect[3]);
let box_width = (rect[2] - rect[0]).abs();
let box_height = (rect[3] - rect[1]).abs();
self.node_counter += 1;
self.page.nodes.push(Node::Path {
id: format!("pdf-link-border-{}-{}", self.page.number, self.node_counter),
d: rectangle_path(x, y, box_width, box_height),
fill_rule: "nonzero".into(),
fill: Paint::None,
stroke: Stroke {
paint: Paint::solid(color),
width,
line_cap: LineCap::Butt,
line_join: LineJoin::Miter,
miter_limit: 10.0,
dash_array: Vec::new(),
dash_offset: 0.0,
},
transform: self.page_matrix,
clip_id: None,
meta: SourceMeta {
kind: "annotation-border".into(),
source_id: format!("page:{}:annotation:link", self.page.number),
..SourceMeta::default()
},
});
}
fn draw_freetext_annotation_fallback(&mut self, annot_dict: &Dictionary) -> Result<()> {
let Some(rect) = annotation_rect(annot_dict, self.document) else {
return Ok(());
};
let Some(contents) = annot_dict
.get_deref(b"Contents", self.document)
.ok()
.and_then(|object| string_bytes(Some(object)))
else {
return Ok(());
};
if contents.len() > MAX_PDF_FORM_VALUE_BYTES {
self.page.warn(format!(
"PDF FreeText annotation contents exceed {MAX_PDF_FORM_VALUE_BYTES} bytes and were omitted"
));
return Ok(());
}
let appearance = annot_dict
.get_deref(b"DA", self.document)
.ok()
.and_then(|object| string_bytes(Some(object)))
.filter(|appearance| appearance.len() <= MAX_PDF_DEFAULT_APPEARANCE_BYTES)
.unwrap_or_default();
if annot_dict
.get_deref(b"DA", self.document)
.ok()
.and_then(|object| string_bytes(Some(object)))
.is_some_and(|appearance| appearance.len() > MAX_PDF_DEFAULT_APPEARANCE_BYTES)
{
self.page.warn(format!(
"PDF FreeText DA exceeds {MAX_PDF_DEFAULT_APPEARANCE_BYTES} bytes; default text styling was used"
));
}
let quadding = integer(annot_dict.get(b"Q").ok()).unwrap_or(0);
self.draw_variable_text_fallback(
&rect,
decode_pdf_annotation_text(contents),
appearance,
quadding,
"freetext",
"PDF FreeText annotation without a normal appearance was rendered from Contents/DA with approximate layout",
)?;
if annot_dict.get(b"RC").is_ok() {
self.page
.warn("PDF FreeText rich-text RC styling was not applied");
}
Ok(())
}
fn draw_widget_text_fallback(&mut self, annot_dict: &Dictionary) -> Result<()> {
let mut current = Some(annot_dict);
let mut visited_parents = HashSet::new();
let mut field_type = None;
let mut value = None;
let mut value_array = None;
let mut value_limit_exceeded = false;
let mut value_name = None;
let mut choice_options = None;
let mut choice_options_seen = false;
let mut appearance = None;
let mut appearance_limit_exceeded = false;
let mut flags = None;
let mut quadding = None;
let mut rich_text = false;
let appearance_state = annot_dict
.get_deref(b"AS", self.document)
.ok()
.and_then(|object| object.as_name().ok())
.map(ToOwned::to_owned);
for _ in 0..64 {
let Some(dictionary) = current else {
break;
};
if field_type.is_none() {
field_type = dictionary
.get_deref(b"FT", self.document)
.ok()
.and_then(|object| object.as_name().ok())
.map(ToOwned::to_owned);
}
if value.is_none()
&& value_array.is_none()
&& let Ok(object) = dictionary.get_deref(b"V", self.document)
{
if let Some(bytes) = string_bytes(Some(object)) {
if bytes.len() <= MAX_PDF_FORM_VALUE_BYTES {
value = Some(bytes.to_vec());
} else {
value_limit_exceeded = true;
}
} else if let Ok(array) = object.as_array() {
value_array = pdf_string_array(self.document, array);
value_limit_exceeded = value_array.is_none();
} else if let Object::Stream(stream) = object {
match stream.decompressed_content_with_limit(MAX_PDF_FORM_VALUE_BYTES) {
Ok(bytes) => value = Some(bytes),
Err(_) => value_limit_exceeded = true,
}
}
}
if value_name.is_none() {
value_name = dictionary
.get_deref(b"V", self.document)
.ok()
.and_then(|object| object.as_name().ok())
.map(ToOwned::to_owned);
}
if appearance.is_none()
&& let Some(bytes) = dictionary
.get_deref(b"DA", self.document)
.ok()
.and_then(|object| string_bytes(Some(object)))
{
if bytes.len() <= MAX_PDF_DEFAULT_APPEARANCE_BYTES {
appearance = Some(bytes.to_vec());
} else {
appearance_limit_exceeded = true;
}
}
if flags.is_none() {
flags = dictionary
.get_deref(b"Ff", self.document)
.ok()
.and_then(|object| integer(Some(object)));
}
if quadding.is_none() {
quadding = dictionary
.get_deref(b"Q", self.document)
.ok()
.and_then(|object| integer(Some(object)));
}
if !choice_options_seen && let Ok(object) = dictionary.get_deref(b"Opt", self.document)
{
choice_options_seen = true;
choice_options = pdf_choice_options(self.document, object);
}
rich_text |= dictionary.get_deref(b"RV", self.document).is_ok();
let Some(parent_id) = dictionary
.get(b"Parent")
.ok()
.and_then(|object| object.as_reference().ok())
else {
break;
};
if !visited_parents.insert(parent_id) {
break;
}
current = self.document.get_dictionary(parent_id).ok();
}
let Some(field_type) = field_type else {
return Ok(());
};
if value_limit_exceeded
&& (field_type.as_slice() == b"Tx" || field_type.as_slice() == b"Ch")
{
self.page.warn(format!(
"PDF form field value or selection list exceeds the {MAX_PDF_FORM_VALUE_BYTES}-byte budget and was omitted"
));
return Ok(());
}
if field_type.as_slice() == b"Btn" {
let flags = flags.unwrap_or(0);
if flags & (1 << 16) != 0 {
return Ok(());
}
let radio = flags & (1 << 15) != 0;
let state = appearance_state
.as_deref()
.or_else(|| (!radio).then_some(value_name.as_deref()).flatten());
return self.draw_button_widget_fallback(annot_dict, radio, state);
}
if field_type.as_slice() == b"Ch" {
let values = value_array
.or_else(|| value.map(|value| vec![value]))
.unwrap_or_default();
if values.is_empty() {
return Ok(());
}
let mut text = String::new();
for (index, selected) in values.iter().enumerate() {
if index > 0 {
text.push('\n');
}
let display = choice_options
.as_deref()
.and_then(|options| pdf_choice_display_value(options, selected))
.unwrap_or(selected);
text.push_str(&decode_pdf_annotation_text(display));
if text.len() > MAX_PDF_CHOICE_TEXT_BYTES {
self.page.warn(format!(
"PDF choice Widget selected text exceeds {MAX_PDF_CHOICE_TEXT_BYTES} bytes and was omitted"
));
return Ok(());
}
}
let (appearance, form_appearance_over_limit) =
bounded_form_appearance(appearance, self.document);
if appearance_limit_exceeded || form_appearance_over_limit {
self.page.warn(format!(
"PDF choice Widget DA exceeds {MAX_PDF_DEFAULT_APPEARANCE_BYTES} bytes; default text styling was used"
));
}
let Some(rect) = annotation_rect(annot_dict, self.document) else {
return Ok(());
};
let result = self.draw_variable_text_fallback(
&rect,
text,
&appearance,
quadding.unwrap_or(0),
"widget-choice",
"PDF choice Widget without a normal appearance was rendered from V/Opt/DA with approximate layout",
);
if choice_options_seen && choice_options.is_none() {
self.page
.warn("PDF choice Widget Opt entries were invalid or over the resource limit");
}
return result;
}
if field_type.as_slice() != b"Tx" {
return Ok(());
}
let Some(value) = value else {
return Ok(());
};
let password = flags.is_some_and(|flags| flags & (1 << 13) != 0);
rich_text |= flags.is_some_and(|flags| flags & (1 << 25) != 0);
let mut text = decode_pdf_annotation_text(&value);
if password {
let masked_length = text.chars().count().min(100_000);
text = "•".repeat(masked_length);
self.page
.warn("PDF password Widget value was masked in the preview");
}
let (appearance, form_appearance_over_limit) =
bounded_form_appearance(appearance, self.document);
if appearance_limit_exceeded || form_appearance_over_limit {
self.page.warn(format!(
"PDF text Widget DA exceeds {MAX_PDF_DEFAULT_APPEARANCE_BYTES} bytes; default text styling was used"
));
}
let Some(rect) = annotation_rect(annot_dict, self.document) else {
return Ok(());
};
let result = self.draw_variable_text_fallback(
&rect,
text,
&appearance,
quadding.unwrap_or(0),
"widget-text",
"PDF text Widget without a normal appearance was rendered from V/DA with approximate layout",
);
if rich_text {
self.page
.warn("PDF rich-text Widget RV styling was not applied");
}
result
}
fn draw_button_widget_fallback(
&mut self,
annotation: &Dictionary,
radio: bool,
state: Option<&[u8]>,
) -> Result<()> {
if self.node_counter >= MAX_PDF_PAGE_NODES {
return Ok(());
}
let Some(rect) = annotation_rect(annotation, self.document) else {
return Ok(());
};
let left = rect[0].min(rect[2]);
let right = rect[0].max(rect[2]);
let bottom = rect[1].min(rect[3]);
let top = rect[1].max(rect[3]);
let width = right - left;
let height = top - bottom;
if width <= 0.0 || height <= 0.0 {
return Ok(());
}
let selected = state.is_some_and(|state| !state.eq_ignore_ascii_case(b"Off"));
let border = if radio {
let radius = width.min(height) * 0.5;
let center_x = (left + right) * 0.5;
let center_y = (bottom + top) * 0.5;
format!(
"M {:.3} {:.3} a{radius:.3} {radius:.3} 0 1 0 {:.3} 0 a{radius:.3} {radius:.3} 0 1 0 -{:.3} 0 Z",
center_x - radius,
center_y,
radius * 2.0,
radius * 2.0,
)
} else {
rectangle_path(left, bottom, width, height)
};
self.node_counter += 1;
self.page.nodes.push(Node::Path {
id: format!(
"pdf-widget-border-{}-{}",
self.page.number, self.node_counter
),
d: border,
fill_rule: "nonzero".into(),
fill: Paint::None,
stroke: Stroke {
paint: Paint::solid("#334155"),
width: 1.0,
line_cap: LineCap::Round,
line_join: LineJoin::Round,
..Default::default()
},
transform: self.page_matrix,
clip_id: None,
meta: SourceMeta {
kind: "annotation-widget-button".into(),
source_id: format!("page:{}:annotation:widget-button", self.page.number),
..SourceMeta::default()
},
});
if selected {
if self.node_counter >= MAX_PDF_PAGE_NODES {
self.page.warn(format!(
"PDF page output reached the {MAX_PDF_PAGE_NODES}-node annotation limit; widget state mark was omitted"
));
return Ok(());
}
let mark = if radio {
let radius = width.min(height) * 0.23;
let center_x = (left + right) * 0.5;
let center_y = (bottom + top) * 0.5;
format!(
"M {:.3} {:.3} a{radius:.3} {radius:.3} 0 1 0 {:.3} 0 a{radius:.3} {radius:.3} 0 1 0 -{:.3} 0 Z",
center_x - radius,
center_y,
radius * 2.0,
radius * 2.0,
)
} else {
format!(
"M {:.3} {:.3} L {:.3} {:.3} L {:.3} {:.3}",
left + width * 0.2,
bottom + height * 0.52,
left + width * 0.43,
bottom + height * 0.25,
left + width * 0.82,
bottom + height * 0.78,
)
};
self.node_counter += 1;
self.page.nodes.push(Node::Path {
id: format!("pdf-widget-mark-{}-{}", self.page.number, self.node_counter),
d: mark,
fill_rule: "nonzero".into(),
fill: if radio {
Paint::solid("#334155")
} else {
Paint::None
},
stroke: if radio {
Stroke::default()
} else {
Stroke {
paint: Paint::solid("#334155"),
width: width.min(height).mul_add(0.04, 0.8),
line_cap: LineCap::Round,
line_join: LineJoin::Round,
..Default::default()
}
},
transform: self.page_matrix,
clip_id: None,
meta: SourceMeta {
kind: "annotation-widget-button-mark".into(),
source_id: format!("page:{}:annotation:widget-button", self.page.number),
..Default::default()
},
});
}
self.page.warn(
"PDF button Widget without a normal appearance was rendered as a simple checkbox/radio marker",
);
Ok(())
}
fn draw_variable_text_fallback(
&mut self,
rect: &[f64],
mut text: String,
appearance: &[u8],
quadding: i64,
kind: &str,
warning: &str,
) -> Result<()> {
const MAX_FREETEXT_CHARS: usize = 100_000;
const MAX_FREETEXT_LINES: usize = 2_048;
let left = rect[0].min(rect[2]);
let right = rect[0].max(rect[2]);
let bottom = rect[1].min(rect[3]);
let top = rect[1].max(rect[3]);
let width = right - left;
let height = top - bottom;
if width <= 0.0 || height <= 0.0 {
return Ok(());
}
let char_count = text.chars().count();
if char_count > MAX_FREETEXT_CHARS {
text = text.chars().take(MAX_FREETEXT_CHARS).collect();
}
let default_size = (height * 0.6).clamp(6.0, 18.0);
let (font_family, font_size, fill, anchor) =
free_text_appearance_style(appearance, quadding, default_size);
let inner_width = (width - 8.0).max(1.0);
let max_chars = (inner_width / (font_size * 0.55).max(1.0))
.floor()
.clamp(1.0, 512.0) as usize;
let geometry_lines = ((height - 4.0).max(font_size) / (font_size * 1.2).max(1.0))
.floor()
.clamp(1.0, MAX_FREETEXT_LINES as f64) as usize;
let remaining_nodes = MAX_PDF_PAGE_NODES.saturating_sub(self.node_counter);
let max_lines = geometry_lines.min(remaining_nodes);
if max_lines == 0 {
self.page.warn(format!(
"PDF page output reached the {MAX_PDF_PAGE_NODES}-node annotation limit; annotation text was omitted"
));
return Ok(());
}
let (lines, was_truncated) = wrap_free_text_annotation(&text, max_chars, max_lines);
if lines.is_empty() {
return Ok(());
}
let x = match anchor {
TextAnchor::Start => left + 4.0,
TextAnchor::Middle => (left + right) / 2.0,
TextAnchor::End => right - 4.0,
};
for (index, line) in lines.iter().enumerate() {
self.node_counter += 1;
self.page.nodes.push(Node::Text {
id: format!("pdf-freetext-{}-{}", self.page.number, self.node_counter),
x,
y: top - 2.0 - font_size - index as f64 * font_size * 1.2,
runs: vec![TextRun {
text: line.clone(),
font_family: font_family.clone(),
font_size,
fill: fill.clone(),
..TextRun::default()
}],
anchor,
transform: self.page_matrix,
opacity: 1.0,
stroke: Stroke::default(),
clip_id: None,
meta: SourceMeta {
kind: format!("annotation-{kind}"),
source_id: format!("page:{}:annotation:{kind}", self.page.number),
..SourceMeta::default()
},
});
}
self.page.warn(warning.to_owned());
if was_truncated || char_count > MAX_FREETEXT_CHARS {
self.page
.warn("PDF annotation text was truncated to fit safety limits or its rectangle");
}
Ok(())
}
fn draw_annotation_appearance(&mut self, stream: &Stream, rect: &[f64]) -> Result<()> {
let bbox = stream
.dict
.get(b"BBox")
.and_then(Object::as_array)
.ok()
.map(|arr| numbers(arr, 4))
.unwrap_or_default();
let form_matrix = stream
.dict
.get(b"Matrix")
.and_then(Object::as_array)
.ok()
.and_then(|matrix| matrix_operands(matrix))
.unwrap_or(IDENTITY);
let map_matrix = if bbox.len() == 4 {
let corners = [
(bbox[0], bbox[1]),
(bbox[2], bbox[1]),
(bbox[2], bbox[3]),
(bbox[0], bbox[3]),
]
.map(|(x, y)| transform_point(form_matrix, x, y));
let txmin = corners
.iter()
.map(|point| point.0)
.fold(f64::INFINITY, f64::min);
let txmax = corners
.iter()
.map(|point| point.0)
.fold(f64::NEG_INFINITY, f64::max);
let tymin = corners
.iter()
.map(|point| point.1)
.fold(f64::INFINITY, f64::min);
let tymax = corners
.iter()
.map(|point| point.1)
.fold(f64::NEG_INFINITY, f64::max);
let bw = txmax - txmin;
let bh = tymax - tymin;
let rw = (rect[2] - rect[0]).abs();
let rh = (rect[3] - rect[1]).abs();
let sx = if bw > 1e-6 { rw / bw } else { 1.0 };
let sy = if bh > 1e-6 { rh / bh } else { 1.0 };
let tx = rect[0].min(rect[2]) - sx * txmin;
let ty = rect[1].min(rect[3]) - sy * tymin;
[sx, 0.0, 0.0, sy, tx, ty]
} else {
[1.0, 0.0, 0.0, 1.0, rect[0], rect[1]]
};
let previous_state = self.state.clone();
let previous_content_base_ctm = self.content_base_ctm;
self.state = GraphicsState::default();
self.state.ctm = compose(map_matrix, form_matrix);
self.content_base_ctm = self.state.ctm;
let form_resources = stream
.dict
.get_deref(b"Resources", self.document)
.and_then(Object::as_dict)
.ok()
.cloned();
let form_fonts = build_resource_font_decoders(
self.document,
form_resources.as_ref(),
self.content_limit,
self.page,
);
let mut previous_fonts = Vec::with_capacity(form_fonts.len());
for (font_name, decoder) in form_fonts {
let previous = self.fonts.insert(font_name.clone(), decoder);
previous_fonts.push((font_name, previous));
}
let mut pushed_resources = false;
if let Some(resources) = form_resources {
self.resources.push(resources.clone());
pushed_resources = true;
}
let content = stream.decompressed_content_with_limit(self.content_limit)?;
let decoded = decode_content_operations(&content, self.content_limit, self.page)?;
let interpret_result = self.interpret(&decoded, 0);
if pushed_resources {
self.resources.pop();
}
for (font_name, previous) in previous_fonts {
if let Some(previous) = previous {
self.fonts.insert(font_name, previous);
} else {
self.fonts.remove(&font_name);
}
}
self.content_base_ctm = previous_content_base_ctm;
self.state = previous_state;
interpret_result?;
Ok(())
}
fn draw_image(&mut self, stream: &Stream, name: &[u8]) -> Result<()> {
if self.marked_content_hidden {
return Ok(());
}
match self.draw_image_inner(stream, name) {
Ok(()) => Ok(()),
Err(error @ (Error::LimitExceeded(_) | Error::InvalidInput(_) | Error::Pdf(_))) => {
self.page.warn(format!(
"PDF image {} was skipped: {error}",
String::from_utf8_lossy(name)
));
Ok(())
}
Err(error) => Err(error),
}
}
fn draw_image_inner(&mut self, stream: &Stream, name: &[u8]) -> Result<()> {
let width = stream
.dict
.get(b"Width")
.and_then(Object::as_i64)
.unwrap_or(1)
.max(1) as usize;
let height = stream
.dict
.get(b"Height")
.and_then(Object::as_i64)
.unwrap_or(1)
.max(1) as usize;
let pixel_count = width.checked_mul(height).ok_or_else(|| {
Error::LimitExceeded(format!(
"PDF image {} dimensions overflow",
String::from_utf8_lossy(name)
))
})?;
let bytes_per_pixel = self.image_expansion_bytes_per_pixel(stream);
let expanded_bytes = pixel_count.checked_mul(bytes_per_pixel).ok_or_else(|| {
Error::LimitExceeded(format!(
"PDF image {} dimensions overflow",
String::from_utf8_lossy(name)
))
})?;
if width > u32::MAX as usize
|| height > u32::MAX as usize
|| expanded_bytes > self.content_limit
{
return Err(Error::LimitExceeded(format!(
"PDF image {} expands to {}x{} pixels at {} byte(s) per pixel; limit is {} bytes",
String::from_utf8_lossy(name),
width,
height,
bytes_per_pixel,
self.content_limit
)));
}
let filters = stream.filters().unwrap_or_default();
let interpolate = stream
.dict
.get(b"Interpolate")
.and_then(Object::as_bool)
.unwrap_or(false);
let has_dct = filters.iter().any(|filter| *filter == b"DCTDecode");
let has_jpx = filters.iter().any(|filter| *filter == b"JPXDecode");
let jpeg_color_space = stream
.dict
.get(b"ColorSpace")
.ok()
.map(|object| self.resolve_color_space_object(object));
let jpeg_passthrough = has_dct
&& stream.dict.get(b"SMask").is_err()
&& stream.dict.get(b"Decode").is_err()
&& jpeg_color_space.as_ref().is_none_or(|color_space| {
matches!(
color_space,
Object::Name(value)
if value == b"DeviceGray"
|| value == b"G"
|| value == b"DeviceRGB"
|| value == b"RGB"
)
});
let (mime, bytes) = if jpeg_passthrough {
(
"image/jpeg",
image_codec_content(stream, self.content_limit)?,
)
} else if has_jpx {
let jpx_bytes = image_codec_content(stream, self.content_limit)?;
if let Some(png_bytes) = self.decode_jpx_pdf_image(
stream,
name,
width,
height,
&jpx_bytes,
jpeg_color_space.as_ref(),
)? {
("image/png", png_bytes)
} else {
#[cfg(not(target_arch = "wasm32"))]
self.page.warn(format!(
"PDF JPX image {} has color, mask, or alpha features that could not be safely normalized; its original JPX data was embedded, so PDF-level masks may not be preserved and a JPX-capable viewer may be required",
String::from_utf8_lossy(name)
));
#[cfg(target_arch = "wasm32")]
self.page.warn(format!(
"PDF JPX image {} could not be decoded in the browser build; its original JPEG 2000 data was embedded, so image display and PDF-level masks may not be preserved",
String::from_utf8_lossy(name)
));
(jpx_media_type(&jpx_bytes), jpx_bytes)
}
} else {
let ccitt = filters.iter().any(|filter| *filter == b"CCITTFaxDecode");
if let Some(filter) = filters
.iter()
.copied()
.find(|filter| UNDECODABLE_IMAGE_FILTERS.contains(filter))
{
self.page.warn(format!(
"PDF image {} uses the unsupported {} filter and was skipped",
String::from_utf8_lossy(name),
String::from_utf8_lossy(filter)
));
return Ok(());
}
let pixels = if has_dct {
decode_pdf_jpeg_content(stream, width, height, self.content_limit)?
} else if ccitt {
ccitt_spec_from_stream(stream, width, height)
.decode_ccitt(&image_codec_content(stream, self.content_limit)?)
.ok_or_else(|| {
Error::InvalidInput(format!(
"PDF CCITT image {} could not be decoded",
String::from_utf8_lossy(name)
))
})?
} else {
decode_predicted_content(self.document, stream, self.content_limit)?
};
let bits = if ccitt || has_dct {
8
} else {
stream
.dict
.get(b"BitsPerComponent")
.and_then(Object::as_i64)
.unwrap_or_else(|_| {
if stream
.dict
.get(b"ImageMask")
.and_then(Object::as_bool)
.unwrap_or(false)
{
1
} else {
8
}
})
};
if !matches!(bits, 1 | 2 | 4 | 8 | 16) {
self.page.warn(format!(
"PDF image {} uses {bits}-bit samples and was skipped",
String::from_utf8_lossy(name)
));
return Ok(());
}
let image_mask = stream
.dict
.get(b"ImageMask")
.and_then(Object::as_bool)
.unwrap_or(false);
let (color_type, output_pixels, width, height) = if image_mask {
let Some(decoded) = decode_stencil_image(
stream,
&pixels,
width,
height,
bits as usize,
&self.state.fill,
) else {
self.page.warn(format!(
"PDF stencil image {} has invalid packed samples and was skipped",
String::from_utf8_lossy(name)
));
return Ok(());
};
(png::ColorType::Rgba, decoded, width, height)
} else {
let color_space = stream
.dict
.get(b"ColorSpace")
.ok()
.map(|object| self.resolve_color_space_object(object));
let components = pdf_color_component_count(self.document, color_space.as_ref());
let mut jpeg_cmyk_stream = stream.clone();
let normalization_stream = if has_dct
&& matches!(
color_space.as_ref(),
Some(Object::Name(value)) if value == b"DeviceCMYK" || value == b"CMYK"
) {
jpeg_cmyk_stream.dict.remove(b"Decode");
&jpeg_cmyk_stream
} else {
stream
};
let Some(pixels) = normalize_image_samples(
normalization_stream,
&pixels,
width,
height,
bits as usize,
components,
color_space.as_ref(),
) else {
self.page.warn(format!(
"PDF image {} has invalid packed samples and was skipped",
String::from_utf8_lossy(name)
));
return Ok(());
};
self.decode_image_samples(stream, pixels, width, height, name)?
};
if has_dct
&& width <= u16::MAX as usize
&& height <= u16::MAX as usize
&& matches!(color_type, png::ColorType::Grayscale | png::ColorType::Rgb)
{
let mut encoded = Vec::new();
let jpeg_color_type = if color_type == png::ColorType::Grayscale {
jpeg_encoder::ColorType::Luma
} else {
jpeg_encoder::ColorType::Rgb
};
jpeg_encoder::Encoder::new(&mut encoded, 92)
.encode(&output_pixels, width as u16, height as u16, jpeg_color_type)
.map_err(|error| {
Error::InvalidInput(format!("cannot encode normalized PDF JPEG: {error}"))
})?;
("image/jpeg", encoded)
} else {
let mut encoded = Vec::new();
{
let mut encoder = png::Encoder::new(&mut encoded, width as u32, height as u32);
encoder.set_color(color_type);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().map_err(|error| {
Error::InvalidInput(format!("cannot encode PDF image as PNG: {error}"))
})?;
writer.write_image_data(&output_pixels).map_err(|error| {
Error::InvalidInput(format!("cannot encode PDF image data: {error}"))
})?;
}
("image/png", encoded)
}
};
self.node_counter += 1;
let image_flip = [1.0, 0.0, 0.0, -1.0, 0.0, 1.0];
let placement = compose(self.page_matrix, compose(self.state.ctm, image_flip));
self.page.nodes.push(Node::Image {
id: format!("pdf-image-{}-{}", self.page.number, self.node_counter),
href: format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
),
x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
transform: placement,
opacity: 1.0,
clip_id: self.state.clip_id.clone(),
meta: SourceMeta {
kind: "image".into(),
source_id: format!(
"page:{}:xobject:{}",
self.page.number,
String::from_utf8_lossy(name)
),
blend_mode: self.state.blend_mode.clone(),
mask_id: self.state.mask_id.clone().unwrap_or_default(),
alpha_is_shape: self.state.alpha_is_shape,
image_rendering: image_rendering_hint(interpolate, width, height, placement),
..SourceMeta::default()
},
});
Ok(())
}
fn decode_jpx_pdf_image(
&mut self,
stream: &Stream,
name: &[u8],
width: usize,
height: usize,
encoded: &[u8],
color_space: Option<&Object>,
) -> Result<Option<Vec<u8>>> {
#[cfg(target_arch = "wasm32")]
{
let _ = (stream, name, width, height, encoded, color_space);
Ok(None)
}
#[cfg(not(target_arch = "wasm32"))]
{
let smask_in_data = stream
.dict
.get(b"SMaskInData")
.and_then(Object::as_i64)
.unwrap_or(0);
let has_external_soft_mask = stream.dict.get(b"SMask").is_ok();
if stream
.dict
.get(b"ImageMask")
.and_then(Object::as_bool)
.unwrap_or(false)
|| stream.dict.get(b"Mask").is_ok()
|| !(0..=2).contains(&smask_in_data)
|| (has_external_soft_mask
&& (smask_in_data != 0
|| self
.jpx_external_soft_mask_dimensions(stream, width, height)
.is_none()))
{
return Ok(None);
}
if encoded.len() > self.content_limit {
return Err(Error::LimitExceeded(format!(
"PDF JPX image stream exceeds {} bytes",
self.content_limit
)));
}
let header = parse_jpx_header(encoded)?;
let declared_width = u32::try_from(width)
.map_err(|_| Error::LimitExceeded("PDF JPX width exceeds 32-bit range".into()))?;
let declared_height = u32::try_from(height)
.map_err(|_| Error::LimitExceeded("PDF JPX height exceeds 32-bit range".into()))?;
if header.width != declared_width || header.height != declared_height {
return Err(Error::InvalidInput(format!(
"PDF JPX image is {}x{}; image dictionary declares {}x{}",
header.width, header.height, width, height
)));
}
let pixel_count = width
.checked_mul(height)
.ok_or_else(|| Error::LimitExceeded("PDF JPX pixel count overflowed".into()))?;
if pixel_count > MAX_PDF_JPX_PIXELS {
return Err(Error::LimitExceeded(format!(
"PDF JPX image contains {pixel_count} pixels; maximum is {MAX_PDF_JPX_PIXELS}"
)));
}
let component_count = header.components.len();
let expected_components = match color_space {
Some(Object::Name(value)) if value == b"DeviceGray" || value == b"G" => Some(1),
Some(Object::Name(value)) if value == b"DeviceRGB" || value == b"RGB" => Some(3),
Some(Object::Array(values)) => {
match values.first().and_then(|value| value.as_name().ok()) {
Some(b"CalGray") => Some(1),
Some(b"CalRGB") => Some(3),
_ => return Ok(None),
}
}
None => None,
_ => return Ok(None),
};
if !matches!(component_count, 1..=4) {
return Ok(None);
}
if let Some(expected_components) = expected_components
&& (if smask_in_data != 0 {
component_count != expected_components + 1
} else {
component_count != expected_components
&& component_count != expected_components + 1
})
{
return Err(Error::InvalidInput(format!(
"PDF JPX has {component_count} components, inconsistent with its color space and embedded mask"
)));
}
let bits = header.components[0].precision;
if !matches!(bits, 1..=16)
|| header.components.iter().any(|component| {
component.precision != bits
|| component.signed
|| component.width != declared_width
|| component.height != declared_height
})
{
return Ok(None);
}
let decoded_sample_bytes = pixel_count
.checked_mul(component_count)
.and_then(|value| value.checked_mul(std::mem::size_of::<i32>()))
.ok_or_else(|| {
Error::LimitExceeded("PDF JPX decoded sample size overflowed".into())
})?;
if decoded_sample_bytes > self.content_limit.min(MAX_PDF_JPX_DECODED_SAMPLE_BYTES) {
return Err(Error::LimitExceeded(format!(
"PDF JPX image needs {decoded_sample_bytes} decoded sample bytes; limit is {}",
self.content_limit.min(MAX_PDF_JPX_DECODED_SAMPLE_BYTES)
)));
}
let image = match Jpeg2000Image::from_bytes(encoded) {
Ok(image) => image,
Err(_) => return Ok(None),
};
if image.width() as usize != width
|| image.height() as usize != height
|| image.components().len() != component_count
{
return Err(Error::InvalidInput(
"PDF JPX decoder dimensions/components do not match the validated header"
.into(),
));
}
let alpha_components = image
.components()
.iter()
.enumerate()
.filter_map(|(index, component)| component.is_alpha().then_some(index))
.collect::<Vec<_>>();
let alpha_component = match (smask_in_data, alpha_components.as_slice()) {
(1 | 2, [index]) => Some(*index),
(1 | 2, []) => match expected_components {
Some(color_components) if component_count == color_components + 1 => {
Some(component_count - 1)
}
None if matches!(component_count, 2 | 4) => Some(component_count - 1),
_ => return Ok(None),
},
(1 | 2, _) => return Ok(None),
(0, []) => match expected_components {
Some(color_components) if component_count == color_components + 1 => {
Some(component_count - 1)
}
_ => None,
},
(0, [index]) => Some(*index),
_ => return Ok(None),
};
let color_component_indices = (0..component_count)
.filter(|index| Some(*index) != alpha_component)
.collect::<Vec<_>>();
let color_component_count = color_component_indices.len();
if !matches!(color_component_count, 1 | 3)
|| expected_components.is_some_and(|expected| expected != color_component_count)
{
return Ok(None);
}
let colorspace_supported = matches!(
(color_component_count, image.color_space()),
(
1,
Jpeg2000ColorSpace::Gray
| Jpeg2000ColorSpace::Unknown
| Jpeg2000ColorSpace::Unspecified,
) | (
3,
Jpeg2000ColorSpace::SRGB
| Jpeg2000ColorSpace::Unknown
| Jpeg2000ColorSpace::Unspecified,
)
);
if !colorspace_supported
|| image
.components()
.iter()
.enumerate()
.any(|(index, component)| {
(component.is_alpha() && Some(index) != alpha_component)
|| component.is_signed()
|| component.precision() != u32::from(bits)
|| component.width() as usize != width
|| component.height() as usize != height
})
{
return Ok(None);
}
let matte_components = if smask_in_data == 2 {
let matte_color_space_supported = match color_space {
Some(Object::Name(value))
if (value == b"DeviceGray" || value == b"G")
&& color_component_count == 1 =>
{
true
}
Some(Object::Name(value))
if (value == b"DeviceRGB" || value == b"RGB")
&& color_component_count == 3 =>
{
true
}
Some(Object::Array(values)) => {
match values.first().and_then(|value| value.as_name().ok()) {
Some(b"CalGray") => color_component_count == 1,
Some(b"CalRGB") => color_component_count == 3,
_ => false,
}
}
None => matches!(
(color_component_count, image.color_space()),
(1, Jpeg2000ColorSpace::Gray) | (3, Jpeg2000ColorSpace::SRGB)
),
_ => false,
};
if !matte_color_space_supported {
return Ok(None);
}
let Some((_, matte_object)) = stream
.dict
.get(b"Matte")
.ok()
.and_then(|object| self.document.dereference(object).ok())
else {
return Ok(None);
};
let Ok(matte_values) = matte_object.as_array() else {
return Ok(None);
};
if matte_values.len() != color_component_count {
return Ok(None);
}
let matte = numbers(matte_values, color_component_count);
if matte.len() != color_component_count
|| matte
.iter()
.any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))
{
return Ok(None);
}
Some(matte)
} else {
None
};
let bits = usize::from(bits);
let row_bits = width
.checked_mul(color_component_count)
.and_then(|value| value.checked_mul(bits))
.ok_or_else(|| Error::LimitExceeded("PDF JPX row size overflowed".into()))?;
let row_bytes = row_bits.div_ceil(8);
let packed_bytes = row_bytes.checked_mul(height).ok_or_else(|| {
Error::LimitExceeded("PDF JPX sample buffer size overflowed".into())
})?;
if packed_bytes > self.content_limit.min(MAX_PDF_JPX_DECODED_SAMPLE_BYTES) {
return Err(Error::LimitExceeded(format!(
"PDF JPX packed samples need {packed_bytes} bytes; limit is {}",
self.content_limit.min(MAX_PDF_JPX_DECODED_SAMPLE_BYTES)
)));
}
let mut packed = vec![0u8; packed_bytes];
let maximum_sample = (1u32 << bits) - 1;
let component_samples = image
.components()
.iter()
.map(|component| component.data())
.collect::<Vec<_>>();
let color_samples = color_component_indices
.iter()
.map(|index| component_samples[*index])
.collect::<Vec<_>>();
if bits == 8 {
for row in 0..height {
for column in 0..width {
let pixel = row * width + column;
for (component_index, samples) in color_samples.iter().enumerate() {
let sample = samples[pixel];
if !(0..=maximum_sample as i32).contains(&sample) {
return Err(Error::InvalidInput(
"PDF JPX sample is outside its declared precision".into(),
));
}
packed[row * row_bytes
+ column * color_component_count
+ component_index] = sample as u8;
}
}
}
} else if bits == 16 {
for row in 0..height {
for column in 0..width {
let pixel = row * width + column;
for (component_index, samples) in color_samples.iter().enumerate() {
let sample = samples[pixel];
if !(0..=maximum_sample as i32).contains(&sample) {
return Err(Error::InvalidInput(
"PDF JPX sample is outside its declared precision".into(),
));
}
let offset = row * row_bytes
+ (column * color_component_count + component_index) * 2;
packed[offset..offset + 2]
.copy_from_slice(&(sample as u16).to_be_bytes());
}
}
}
} else {
for row in 0..height {
let row_bit_start = row * row_bytes * 8;
for column in 0..width {
let pixel = row * width + column;
for (component_index, samples) in color_samples.iter().enumerate() {
let sample = samples[pixel];
if !(0..=maximum_sample as i32).contains(&sample) {
return Err(Error::InvalidInput(
"PDF JPX sample is outside its declared precision".into(),
));
}
let start = row_bit_start
+ (column * color_component_count + component_index) * bits;
for bit in 0..bits {
if (sample as u32 >> (bits - bit - 1)) & 1 != 0 {
let target = start + bit;
packed[target / 8] |= 1 << (7 - target % 8);
}
}
}
}
}
}
if let Some(matte) = matte_components {
let alpha_component = alpha_component.ok_or_else(|| {
Error::InvalidInput(
"PDF JPX SMaskInData=2 image has no opacity component".into(),
)
})?;
unblend_jpx_matte_samples(
&mut packed,
JpxSampleLayout {
row_bytes,
width,
height,
components: color_component_count,
bits,
},
component_samples[alpha_component],
maximum_sample,
&matte,
)?;
}
let mut stream_without_decode = stream.clone();
stream_without_decode.dict.remove(b"Decode");
let samples = normalize_image_samples(
&stream_without_decode,
&packed,
width,
height,
bits,
color_component_count,
color_space,
)
.ok_or_else(|| Error::InvalidInput("PDF JPX samples cannot be normalized".into()))?;
let (mut color_type, mut output, final_width, final_height) =
self.decode_image_samples(&stream_without_decode, samples, width, height, name)?;
if final_width != width || final_height != height {
return Err(Error::InvalidInput(
"PDF JPX image dimensions changed during sample normalization".into(),
));
}
if matches!(smask_in_data, 1 | 2) {
let color_bytes_per_pixel = match color_type {
png::ColorType::Grayscale => 1usize,
png::ColorType::Rgb => 3usize,
_ => return Ok(None),
};
let expected_color_bytes = pixel_count
.checked_mul(color_bytes_per_pixel)
.ok_or_else(|| Error::LimitExceeded("PDF JPX output size overflowed".into()))?;
if output.len() != expected_color_bytes {
return Ok(None);
}
let output_bytes_per_pixel = color_bytes_per_pixel + 1;
let output_bytes = pixel_count
.checked_mul(output_bytes_per_pixel)
.ok_or_else(|| Error::LimitExceeded("PDF JPX RGBA size overflowed".into()))?;
if output_bytes > self.content_limit.min(MAX_PDF_JPX_PNG_BYTES) {
return Err(Error::LimitExceeded(format!(
"PDF JPX image with alpha needs {output_bytes} normalized bytes; limit is {}",
self.content_limit.min(MAX_PDF_JPX_PNG_BYTES)
)));
}
let alpha_samples = component_samples[alpha_component.unwrap()];
output.resize(output_bytes, 0);
for pixel in (0..pixel_count).rev() {
let source_start = pixel * color_bytes_per_pixel;
let target_start = pixel * output_bytes_per_pixel;
let alpha = alpha_samples[pixel];
if !(0..=maximum_sample as i32).contains(&alpha) {
return Err(Error::InvalidInput(
"PDF JPX alpha sample is outside its declared precision".into(),
));
}
output.copy_within(
source_start..source_start + color_bytes_per_pixel,
target_start,
);
output[target_start + color_bytes_per_pixel] =
((alpha as u32 * 255 + maximum_sample / 2) / maximum_sample) as u8;
}
color_type = if color_type == png::ColorType::Grayscale {
png::ColorType::GrayscaleAlpha
} else {
png::ColorType::Rgba
};
}
if output.len() > self.content_limit.min(MAX_PDF_JPX_PNG_BYTES) {
return Err(Error::LimitExceeded(format!(
"PDF JPX normalized image exceeds {} bytes",
self.content_limit.min(MAX_PDF_JPX_PNG_BYTES)
)));
}
if !matches!(
color_type,
png::ColorType::Grayscale
| png::ColorType::Rgb
| png::ColorType::GrayscaleAlpha
| png::ColorType::Rgba
) {
return Ok(None);
}
let mut png = Vec::new();
{
let mut encoder = png::Encoder::new(&mut png, width as u32, height as u32);
encoder.set_color(color_type);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().map_err(|error| {
Error::InvalidInput(format!("cannot encode PDF JPX PNG: {error}"))
})?;
writer.write_image_data(&output).map_err(|error| {
Error::InvalidInput(format!("cannot encode PDF JPX PNG samples: {error}"))
})?;
}
if png.len() > self.content_limit.min(MAX_PDF_JPX_PNG_BYTES) {
return Err(Error::LimitExceeded(format!(
"PDF JPX PNG exceeds {} bytes",
self.content_limit.min(MAX_PDF_JPX_PNG_BYTES)
)));
}
Ok(Some(png))
}
}
fn jpx_external_soft_mask_dimensions(
&self,
stream: &Stream,
image_width: usize,
image_height: usize,
) -> Option<(usize, usize)> {
let mask_reference = stream.dict.get(b"SMask").ok()?;
let (_, mask_object) = self.document.dereference(mask_reference).ok()?;
let mask = mask_object.as_stream().ok()?;
if mask.dict.get(b"Matte").is_ok()
|| mask.dict.get(b"Mask").is_ok()
|| mask.dict.get(b"SMask").is_ok()
|| mask
.dict
.get(b"ImageMask")
.and_then(Object::as_bool)
.unwrap_or(false)
{
return None;
}
let color_space = mask
.dict
.get(b"ColorSpace")
.ok()
.map(|object| self.resolve_color_space_object(object));
if !matches!(
color_space.as_ref(),
Some(Object::Name(value)) if value == b"DeviceGray" || value == b"G"
) {
return None;
}
let mask_bits = mask
.dict
.get(b"BitsPerComponent")
.and_then(Object::as_i64)
.ok()?;
if !matches!(mask_bits, 1 | 2 | 4 | 8 | 16) {
return None;
}
let mask_width =
usize::try_from(mask.dict.get(b"Width").and_then(Object::as_i64).ok()?).ok()?;
let mask_height =
usize::try_from(mask.dict.get(b"Height").and_then(Object::as_i64).ok()?).ok()?;
if mask_width == 0 || mask_height == 0 {
return None;
}
let mask_pixels = mask_width.checked_mul(mask_height)?;
let target_width = image_width.max(mask_width);
let target_height = image_height.max(mask_height);
let target_pixels = target_width.checked_mul(target_height)?;
let target_rgba_bytes = target_pixels.checked_mul(4)?;
if mask_pixels > MAX_PDF_JPX_PIXELS
|| target_pixels > MAX_PDF_JPX_PIXELS
|| target_rgba_bytes > self.content_limit.min(MAX_PDF_JPX_PNG_BYTES)
{
return None;
}
if mask
.filters()
.unwrap_or_default()
.iter()
.any(|filter| matches!(*filter, b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode"))
{
return None;
}
Some((mask_width, mask_height))
}
fn image_expansion_bytes_per_pixel(&mut self, stream: &Stream) -> usize {
if stream
.dict
.get(b"ImageMask")
.and_then(Object::as_bool)
.unwrap_or(false)
{
return 4;
}
let color_space = stream
.dict
.get(b"ColorSpace")
.ok()
.map(|object| self.resolve_color_space_object(object));
let input_components = pdf_color_component_count(self.document, color_space.as_ref());
let output_components: usize = match color_space.as_ref() {
Some(Object::Name(value)) if value == b"DeviceGray" || value == b"G" => 1,
Some(Object::Array(array))
if array.first().and_then(|value| value.as_name().ok()) == Some(b"CalGray") =>
{
1
}
_ => 3,
};
let alpha = usize::from(
stream.dict.get(b"SMask").is_ok()
|| stream
.dict
.get(b"SMaskInData")
.and_then(Object::as_i64)
.unwrap_or(0)
!= 0,
);
input_components
.max(output_components.saturating_add(alpha))
.max(1)
}
fn decode_image_samples(
&mut self,
stream: &Stream,
pixels: Vec<u8>,
width: usize,
height: usize,
name: &[u8],
) -> Result<(png::ColorType, Vec<u8>, usize, usize)> {
let pixel_count = width.saturating_mul(height);
let color_space = stream
.dict
.get(b"ColorSpace")
.ok()
.map(|object| self.resolve_color_space_object(object));
let (mut color_type, mut output) = match color_space.as_ref() {
Some(Object::Name(value)) if value == b"DeviceGray" || value == b"G" => {
(png::ColorType::Grayscale, pixels)
}
Some(Object::Name(value)) if value == b"DeviceCMYK" => {
(png::ColorType::Rgb, cmyk_samples_to_rgb(&pixels))
}
Some(Object::Array(array))
if array.first().and_then(|value| value.as_name().ok()) == Some(b"Indexed") =>
{
let base = array.get(1);
let palette = array
.get(3)
.and_then(|value| {
self.document
.dereference(value)
.ok()
.map(|(_, value)| value)
})
.and_then(object_bytes)
.unwrap_or_default();
let components = pdf_color_component_count(self.document, base);
if palette.is_empty() || components == 0 {
self.page.warn(format!(
"PDF indexed image {} has no usable palette",
String::from_utf8_lossy(name)
));
return Ok((png::ColorType::Grayscale, pixels, width, height));
}
let base_array = base
.and_then(|value| self.document.dereference(value).ok())
.and_then(|(_, value)| value.as_array().ok());
let is_lab = base_array
.and_then(|array| array.first().and_then(|value| value.as_name().ok()))
== Some(b"Lab");
let lab_range = is_lab
.then(|| {
base_array
.and_then(|array| array.get(1))
.and_then(|value| self.document.dereference(value).ok())
.and_then(|(_, value)| value.as_dict().ok())
.and_then(|dictionary| {
dictionary.get(b"Range").and_then(Object::as_array).ok()
})
.map(|values| numbers(values, 4))
.filter(|values| values.len() == 4)
})
.flatten()
.unwrap_or_else(|| vec![-100.0, 100.0, -100.0, 100.0]);
let mut rgb = Vec::with_capacity(pixel_count.saturating_mul(3));
for index in pixels {
let offset = usize::from(index).saturating_mul(components);
let converted = palette
.get(offset..offset.saturating_add(components))
.map(|sample| {
sample
.iter()
.enumerate()
.map(|(component, value)| {
let normalized = f64::from(*value) / 255.0;
if !is_lab {
return normalized;
}
let (minimum, maximum) = match component {
0 => (0.0, 100.0),
1 => (lab_range[0], lab_range[1]),
_ => (lab_range[2], lab_range[3]),
};
minimum + normalized * (maximum - minimum)
})
.collect::<Vec<_>>()
})
.and_then(|sample| components_to_rgb(self.document, base, &sample, 0));
rgb.extend_from_slice(&converted.map(rgb_to_bytes).unwrap_or([0, 0, 0]));
}
(png::ColorType::Rgb, rgb)
}
Some(Object::Array(array))
if array.first().and_then(|value| value.as_name().ok()) == Some(b"ICCBased") =>
{
let components = array
.get(1)
.and_then(|value| {
self.document
.dereference(value)
.ok()
.map(|(_, value)| value)
})
.and_then(|value| value.as_stream().ok())
.and_then(|profile| profile.dict.get(b"N").and_then(Object::as_i64).ok())
.unwrap_or(3);
match components {
1 => (png::ColorType::Grayscale, pixels),
4 => (png::ColorType::Rgb, cmyk_samples_to_rgb(&pixels)),
_ => (png::ColorType::Rgb, pixels),
}
}
Some(Object::Array(array))
if matches!(
array.first().and_then(|value| value.as_name().ok()),
Some(b"Separation" | b"DeviceN" | b"Lab" | b"CalGray" | b"CalRGB")
) =>
{
let components = pdf_color_component_count(self.document, color_space.as_ref());
if components == 0 || pixels.len() != pixel_count.saturating_mul(components) {
self.page.warn(format!(
"PDF image {} has incompatible color-space samples",
String::from_utf8_lossy(name)
));
return Ok((png::ColorType::Rgb, pixels, width, height));
}
let mut rgb = Vec::with_capacity(pixel_count.saturating_mul(3));
let is_lab = array.first().and_then(|value| value.as_name().ok()) == Some(b"Lab");
let explicit_decode = stream
.dict
.get(b"Decode")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX));
let lab_range = array
.get(1)
.and_then(|value| {
self.document
.dereference(value)
.ok()
.map(|(_, value)| value)
})
.and_then(|value| value.as_dict().ok())
.and_then(|dictionary| dictionary.get(b"Range").and_then(Object::as_array).ok())
.map(|values| numbers(values, 4))
.filter(|values| values.len() == 4)
.unwrap_or_else(|| vec![-100.0, 100.0, -100.0, 100.0]);
for sample in pixels.chunks_exact(components) {
let values = sample
.iter()
.enumerate()
.map(|(component, value)| {
let normalized = f64::from(*value) / 255.0;
if !is_lab {
return normalized;
}
let (minimum, maximum) = explicit_decode
.as_ref()
.and_then(|decode| {
Some((
*decode.get(component * 2)?,
*decode.get(component * 2 + 1)?,
))
})
.unwrap_or_else(|| match component {
0 => (0.0, 100.0),
1 => (lab_range[0], lab_range[1]),
_ => (lab_range[2], lab_range[3]),
});
minimum + normalized * (maximum - minimum)
})
.collect::<Vec<_>>();
let converted =
components_to_rgb(self.document, color_space.as_ref(), &values, 0);
rgb.extend(converted.map(rgb_to_bytes).unwrap_or([0, 0, 0]));
}
(png::ColorType::Rgb, rgb)
}
Some(Object::Name(value)) if value == b"DeviceRGB" || value == b"RGB" => {
(png::ColorType::Rgb, pixels)
}
_ => {
let components = pixels.len().checked_div(pixel_count.max(1)).unwrap_or(0);
self.page.warn(format!(
"PDF image {} color space was inferred from {components} sample component(s)",
String::from_utf8_lossy(name)
));
match components {
1 => (png::ColorType::Grayscale, pixels),
4 => (png::ColorType::Rgb, cmyk_samples_to_rgb(&pixels)),
_ => (png::ColorType::Rgb, pixels),
}
}
};
let components = match color_type {
png::ColorType::Grayscale => 1,
png::ColorType::Rgb => 3,
_ => 0,
};
let expected = pixel_count.saturating_mul(components);
if output.len() != expected {
return Err(Error::InvalidInput(format!(
"PDF image {} decoded to {} bytes; expected {expected}",
String::from_utf8_lossy(name),
output.len()
)));
}
let mut final_width = width;
let mut final_height = height;
if let Ok(mask_object) = stream.dict.get(b"SMask")
&& let Ok((_, mask_object)) = self.document.dereference(mask_object)
&& let Ok(mask) = mask_object.as_stream()
{
let mask_width = mask
.dict
.get(b"Width")
.and_then(Object::as_i64)
.unwrap_or(width as i64)
.max(1) as usize;
let mask_height = mask
.dict
.get(b"Height")
.and_then(Object::as_i64)
.unwrap_or(height as i64)
.max(1) as usize;
let mask_bits = mask
.dict
.get(b"BitsPerComponent")
.and_then(Object::as_i64)
.unwrap_or(8);
let alpha = decode_soft_mask_content(
self.document,
mask,
mask_width,
mask_height,
self.content_limit,
)?;
let alpha = if matches!(mask_bits, 1 | 2 | 4 | 8 | 16) {
normalize_image_samples(
mask,
&alpha,
mask_width,
mask_height,
mask_bits as usize,
1,
Some(&Object::Name(b"DeviceGray".to_vec())),
)
.unwrap_or(alpha)
} else {
alpha
};
if alpha.len() == mask_width.saturating_mul(mask_height) {
let target_width = width.max(mask_width);
let target_height = height.max(mask_height);
let target_pixels = target_width.checked_mul(target_height);
let output_bytes = target_pixels
.and_then(|pixels| pixels.checked_mul(components.saturating_add(1)));
if output_bytes.is_none_or(|bytes| bytes > self.content_limit) {
self.page.warn(format!(
"PDF image {} soft mask output exceeds the {} byte image limit; the base image is shown without its soft mask",
String::from_utf8_lossy(name),
self.content_limit
));
return Ok((color_type, output, final_width, final_height));
}
output = resample_image_with_alpha(
&output,
(width, height),
components,
&alpha,
(mask_width, mask_height),
(target_width, target_height),
);
color_type = if components == 1 {
png::ColorType::GrayscaleAlpha
} else {
png::ColorType::Rgba
};
final_width = target_width;
final_height = target_height;
} else {
self.page.warn(format!(
"PDF image {} soft mask decoded to the wrong number of samples",
String::from_utf8_lossy(name)
));
}
}
Ok((color_type, output, final_width, final_height))
}
fn lookup_xobject(&self, name: &[u8]) -> Option<(Option<ObjectId>, Stream)> {
for resources in self
.resources
.iter()
.rev()
.chain(page_resource_dicts(self.document, self.page_id))
{
let Ok(xobjects) = resources
.get_deref(b"XObject", self.document)
.and_then(Object::as_dict)
else {
continue;
};
if let Ok(object) = xobjects.get(name) {
let object_id = object.as_reference().ok();
if let Ok((_, resolved)) = self.document.dereference(object)
&& let Ok(stream) = resolved.as_stream()
{
return Some((object_id, stream.clone()));
}
}
}
None
}
fn is_oc_visible(&self, object_id: Option<ObjectId>, object: &Object) -> bool {
let Ok(dictionary) = object.as_dict() else {
return true;
};
let type_name = dictionary
.get(b"Type")
.and_then(Object::as_name)
.unwrap_or_default();
if type_name == b"OCMD" {
let policy = dictionary
.get(b"P")
.and_then(Object::as_name)
.unwrap_or(b"AnyOn");
let members = match dictionary.get(b"OCGs") {
Ok(Object::Array(items)) => items.clone(),
Ok(reference @ Object::Reference(_)) => {
match self.document.dereference(reference) {
Ok((_, Object::Array(items))) => items.clone(),
Ok(_) => vec![reference.clone()],
Err(_) => Vec::new(),
}
}
Ok(other) => vec![other.clone()],
Err(_) => Vec::new(),
};
if members.is_empty() {
return true;
}
let states = members
.iter()
.filter_map(|member| {
self.document.dereference(member).ok().map(|(_, resolved)| {
self.is_oc_visible(member.as_reference().ok(), resolved)
})
})
.collect::<Vec<_>>();
return match policy {
b"AllOn" => states.iter().all(|visible| *visible),
b"AnyOff" => states.iter().any(|visible| !*visible),
b"AllOff" => states.iter().all(|visible| !*visible),
_ => states.iter().any(|visible| *visible),
};
}
object_id.is_none_or(|id| !self.hidden_ocgs.contains(&id))
}
fn xobject_oc_visible(&self, dictionary: &Dictionary) -> bool {
let Ok(reference) = dictionary.get(b"OC") else {
return true;
};
match self.document.dereference(reference) {
Ok((_, resolved)) => self.is_oc_visible(reference.as_reference().ok(), resolved),
Err(_) => true,
}
}
fn draw_shading(&mut self, name: &[u8]) -> Result<()> {
if self.marked_content_hidden {
return Ok(());
}
let Some(shading) = self.lookup_named_resource(b"Shading", name) else {
self.page.warn(format!(
"PDF shading {} was not found",
String::from_utf8_lossy(name)
));
return Ok(());
};
let dictionary = match &shading {
Object::Dictionary(dictionary) => dictionary,
Object::Stream(stream) => &stream.dict,
_ => {
self.page.warn(format!(
"PDF shading {} is not a dictionary or stream",
String::from_utf8_lossy(name)
));
return Ok(());
}
};
let shading_type = dictionary
.get(b"ShadingType")
.and_then(Object::as_i64)
.unwrap_or(0);
if shading_type == 1 {
return self.draw_function_shading(dictionary, name);
}
if matches!(shading_type, 4..=7) {
return self.draw_mesh_shading(&shading, dictionary, shading_type, name);
}
if !matches!(shading_type, 2 | 3) {
if shading_type == 0 {
self.page
.warn("PDF shading resource has no valid /ShadingType");
} else {
self.page.warn(format!(
"PDF shading type {shading_type} is detected; tessellation is pending"
));
}
return Ok(());
}
let coordinates = dictionary
.get(b"Coords")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 6))
.unwrap_or_default();
let required = if shading_type == 2 { 4 } else { 6 };
if coordinates.len() != required {
self.page.warn(format!(
"PDF shading {} has invalid /Coords",
String::from_utf8_lossy(name)
));
return Ok(());
}
let Some(function) = dictionary.get(b"Function").ok() else {
self.page.warn(format!(
"PDF shading {} has no /Function",
String::from_utf8_lossy(name)
));
return Ok(());
};
let domain = dictionary
.get(b"Domain")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 2))
.filter(|values| values.len() == 2)
.unwrap_or_else(|| vec![0.0, 1.0]);
let color_space = dictionary.get(b"ColorSpace").ok();
let extend = dictionary
.get(b"Extend")
.and_then(Object::as_array)
.ok()
.map(|values| {
[
values
.first()
.and_then(|value| value.as_bool().ok())
.unwrap_or(false),
values
.get(1)
.and_then(|value| value.as_bool().ok())
.unwrap_or(false),
]
})
.unwrap_or([false, false]);
let mut stops = Vec::with_capacity(33);
for index in 0..=32 {
let offset = f64::from(index) / 32.0;
let input = domain[0] + (domain[1] - domain[0]) * offset;
let Some(components) = evaluate_pdf_function(self.document, function, input, 0) else {
self.page.warn(format!(
"PDF shading {} uses an unsupported function",
String::from_utf8_lossy(name)
));
return Ok(());
};
stops.push(GradientStop {
offset,
color: components_to_color(self.document, color_space, &components),
opacity: self.state.fill_alpha,
});
}
apply_gradient_extension(&mut stops, extend);
let transform = compose(self.page_matrix, self.state.ctm);
let paint = if shading_type == 2 {
let (x1, y1, x2, y2) = transformed_axial_axis(
transform,
coordinates[0],
coordinates[1],
coordinates[2],
coordinates[3],
)
.unwrap_or_else(|| {
let first = transform_point(transform, coordinates[0], coordinates[1]);
let second = transform_point(transform, coordinates[2], coordinates[3]);
(first.0, first.1, second.0, second.1)
});
Paint::LinearGradient(Box::new(LinearGradient {
x1,
y1,
x2,
y2,
stops,
}))
} else {
let mut first_radius = coordinates[2].max(0.0);
let mut second_radius = coordinates[5].max(0.0);
let mut first_x = coordinates[0];
let mut first_y = coordinates[1];
let mut second_x = coordinates[3];
let mut second_y = coordinates[4];
let center_distance = (second_x - first_x).hypot(second_y - first_y);
if center_distance + first_radius.min(second_radius)
> first_radius.max(second_radius) + 1e-9
{
return self.draw_noncontained_radial(
&coordinates,
function,
&domain,
color_space,
extend,
transform,
name,
);
}
let mut stops = stops;
if first_radius > second_radius {
std::mem::swap(&mut first_radius, &mut second_radius);
std::mem::swap(&mut first_x, &mut second_x);
std::mem::swap(&mut first_y, &mut second_y);
for stop in &mut stops {
stop.offset = 1.0 - stop.offset;
}
stops.reverse();
}
Paint::RadialGradient(Box::new(RadialGradient {
fx: first_x,
fy: first_y,
fr: first_radius,
cx: second_x,
cy: second_y,
radius: second_radius.max(1e-9),
transform,
stops,
}))
};
let mut clip_id = self.state.clip_id.clone();
if let Ok(bbox) = dictionary.get(b"BBox").and_then(Object::as_array) {
let bbox = numbers(bbox, 4);
if bbox.len() == 4 {
let parent_id = clip_id.clone();
self.clip_counter += 1;
let id = format!("pdf-clip-{}-{}", self.page.number, self.clip_counter);
self.page.clips.push(crate::ir::ClipPath {
id: id.clone(),
d: rectangle_path(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]),
transform,
fill_rule: "nonzero".into(),
parent_id,
additional_paths: Vec::new(),
});
clip_id = Some(id);
}
}
self.node_counter += 1;
self.page.nodes.push(Node::Path {
id: format!("pdf-shading-{}-{}", self.page.number, self.node_counter),
d: rectangle_path(0.0, 0.0, self.page.width, self.page.height),
fill_rule: "nonzero".into(),
fill: paint,
stroke: Stroke::default(),
transform: IDENTITY,
clip_id,
meta: SourceMeta {
kind: "gradient".into(),
source_id: format!(
"page:{}:shading:{}",
self.page.number,
String::from_utf8_lossy(name)
),
blend_mode: self.state.blend_mode.clone(),
mask_id: self.state.mask_id.clone().unwrap_or_default(),
alpha_is_shape: self.state.alpha_is_shape,
..SourceMeta::default()
},
});
Ok(())
}
fn draw_function_shading(&mut self, dictionary: &Dictionary, name: &[u8]) -> Result<()> {
let Some(function) = dictionary.get(b"Function").ok() else {
self.page.warn(format!(
"PDF function shading {} has no /Function",
String::from_utf8_lossy(name)
));
return Ok(());
};
let domain = dictionary
.get(b"Domain")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 4))
.filter(|values| values.len() == 4)
.unwrap_or_else(|| vec![0.0, 1.0, 0.0, 1.0]);
let shading_matrix = dictionary
.get(b"Matrix")
.and_then(Object::as_array)
.ok()
.and_then(|values| matrix_operands(values))
.unwrap_or(IDENTITY);
let transform = compose(compose(self.page_matrix, self.state.ctm), shading_matrix);
let color_space = dictionary.get(b"ColorSpace").ok();
let mut clip_id = self.state.clip_id.clone();
if let Ok(bbox) = dictionary.get(b"BBox").and_then(Object::as_array) {
let bbox = numbers(bbox, 4);
if bbox.len() == 4 {
self.clip_counter += 1;
let id = format!("pdf-clip-{}-{}", self.page.number, self.clip_counter);
self.page.clips.push(crate::ir::ClipPath {
id: id.clone(),
d: rectangle_path(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]),
transform: compose(self.page_matrix, self.state.ctm),
fill_rule: "nonzero".into(),
parent_id: clip_id,
additional_paths: Vec::new(),
});
clip_id = Some(id);
}
}
let Some((nodes, truncated)) = build_function_shading_cell_nodes(
self.document,
function,
&domain,
transform,
color_space,
self.state.fill_alpha,
&self.state.blend_mode,
self.state.mask_id.as_deref().unwrap_or_default(),
self.state.alpha_is_shape,
clip_id,
self.page.number,
"pdf-function-cell",
&String::from_utf8_lossy(name),
) else {
self.page.warn(format!(
"PDF function shading {} uses an unsupported function",
String::from_utf8_lossy(name)
));
return Ok(());
};
if truncated {
self.page.warn(format!(
"PDF function shading {} is too fine-grained to render exactly; showing a lower-resolution approximation",
String::from_utf8_lossy(name)
));
}
self.node_counter += nodes.len();
self.page.nodes.extend(nodes);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn draw_noncontained_radial(
&mut self,
coordinates: &[f64],
function: &Object,
domain: &[f64],
color_space: Option<&Object>,
extend: [bool; 2],
transform: Matrix,
name: &[u8],
) -> Result<()> {
let Some(inverse) = inverse_matrix(transform) else {
self.page
.warn("PDF noncontained radial shading has a singular transform");
return Ok(());
};
let field = NoncontainedRadialField {
document: self.document,
function,
coordinates: [
coordinates[0],
coordinates[1],
coordinates[2],
coordinates[3],
coordinates[4],
coordinates[5],
],
domain: [domain[0], domain[1]],
extend,
inverse,
};
const MAX_CELLS: usize = 100_000;
let mut cells = Vec::new();
let columns = 10usize;
let rows = 5usize;
let cell_width = self.page.width / columns as f64;
let cell_height = self.page.height / rows as f64;
for row in 0..rows {
for column in 0..columns {
adaptive_radial_cells(
&field,
RadialBounds {
x: column as f64 * cell_width,
y: row as f64 * cell_height,
width: cell_width,
height: cell_height,
},
0,
&mut cells,
MAX_CELLS,
);
}
}
if cells.len() >= MAX_CELLS {
return Err(Error::LimitExceeded(format!(
"PDF noncontained radial shading exceeds {MAX_CELLS} vector cells"
)));
}
for (index, cell) in cells.into_iter().enumerate() {
self.node_counter += 1;
self.page.nodes.push(Node::Path {
id: format!("pdf-radial-cell-{}-{}", self.page.number, index + 1),
d: rectangle_path(
cell.bounds.x,
cell.bounds.y,
cell.bounds.width,
cell.bounds.height,
),
fill_rule: "nonzero".into(),
fill: Paint::Solid {
color: components_to_color(self.document, color_space, &cell.components),
opacity: self.state.fill_alpha * cell.coverage,
},
stroke: Stroke::default(),
transform: IDENTITY,
clip_id: self.state.clip_id.clone(),
meta: SourceMeta {
kind: "radial-field-cell".into(),
source_id: format!(
"page:{}:shading:{}",
self.page.number,
String::from_utf8_lossy(name)
),
blend_mode: self.state.blend_mode.clone(),
mask_id: self.state.mask_id.clone().unwrap_or_default(),
alpha_is_shape: self.state.alpha_is_shape,
shape_rendering: "crispEdges".into(),
..SourceMeta::default()
},
});
}
Ok(())
}
fn draw_mesh_shading(
&mut self,
shading: &Object,
dictionary: &Dictionary,
shading_type: i64,
name: &[u8],
) -> Result<()> {
let Some((triangles, subdivisions, color_space)) = parse_mesh_shading_triangles(
self.document,
shading,
dictionary,
shading_type,
self.content_limit,
self.mesh_output_count.get(),
name,
self.page,
)?
else {
return Ok(());
};
let transform = compose(self.page_matrix, self.state.ctm);
let nodes = build_mesh_triangle_nodes(
self.document,
&triangles,
subdivisions,
color_space.as_ref(),
transform,
self.state.fill_alpha,
&self.state.blend_mode,
self.state.mask_id.as_deref().unwrap_or_default(),
self.state.alpha_is_shape,
self.state.clip_id.clone(),
self.page.number,
"pdf-mesh",
&String::from_utf8_lossy(name),
&self.mesh_output_count,
);
self.node_counter += nodes.len();
self.page.nodes.extend(nodes);
Ok(())
}
fn lookup_named_resource(&self, category: &[u8], name: &[u8]) -> Option<Object> {
self.lookup_named_resource_with_id(category, name)
.map(|(_, object)| object)
}
fn lookup_named_resource_with_id(
&self,
category: &[u8],
name: &[u8],
) -> Option<(Option<ObjectId>, Object)> {
for resources in self
.resources
.iter()
.rev()
.chain(page_resource_dicts(self.document, self.page_id))
{
let Ok(values) = resources
.get_deref(category, self.document)
.and_then(Object::as_dict)
else {
continue;
};
if let Ok(object) = values.get(name)
&& let Ok((_, resolved)) = self.document.dereference(object)
{
return Some((object.as_reference().ok(), resolved.clone()));
}
}
None
}
fn apply_ext_gstate(&mut self, name: &[u8]) -> Result<()> {
let mut soft_mask = None::<Object>;
for resources in self
.resources
.iter()
.rev()
.chain(page_resource_dicts(self.document, self.page_id))
{
let Ok(states) = resources
.get_deref(b"ExtGState", self.document)
.and_then(Object::as_dict)
else {
continue;
};
let Ok(value) = states.get(name) else {
continue;
};
let Ok((_, value)) = self.document.dereference(value) else {
continue;
};
let Ok(dictionary) = value.as_dict() else {
continue;
};
if let Ok(alpha) = dictionary.get(b"ca").and_then(Object::as_float) {
self.state.fill_alpha = f64::from(alpha).clamp(0.0, 1.0);
set_paint_opacity(&mut self.state.fill, self.state.fill_alpha);
}
if let Ok(alpha) = dictionary.get(b"CA").and_then(Object::as_float) {
self.state.stroke_alpha = f64::from(alpha).clamp(0.0, 1.0);
set_paint_opacity(&mut self.state.stroke, self.state.stroke_alpha);
}
if let Some(width) = number(dictionary.get(b"LW").ok()) {
self.state.line_width = width.max(0.0);
}
if let Some(cap) = integer(dictionary.get(b"LC").ok()) {
self.state.line_cap = match cap {
1 => LineCap::Round,
2 => LineCap::Square,
_ => LineCap::Butt,
};
}
if let Some(join) = integer(dictionary.get(b"LJ").ok()) {
self.state.line_join = match join {
1 => LineJoin::Round,
2 => LineJoin::Bevel,
_ => LineJoin::Miter,
};
}
if let Some(limit) = number(dictionary.get(b"ML").ok()) {
self.state.miter_limit = limit.max(1.0);
}
if let Ok(dash) = dictionary.get(b"D").and_then(Object::as_array) {
self.state.dash_array = dash
.first()
.and_then(|object| object.as_array().ok())
.map(|values| {
values
.iter()
.filter_map(|value| number(Some(value)))
.collect()
})
.unwrap_or_default();
self.state.dash_offset = number(dash.get(1)).unwrap_or(0.0);
}
if let Ok(alpha_is_shape) = dictionary.get(b"AIS").and_then(Object::as_bool) {
self.state.alpha_is_shape = alpha_is_shape;
}
if let Ok(blend) = dictionary.get(b"BM") {
let blend = match blend {
Object::Name(name) => Some(name.as_slice()),
Object::Array(values) => values.first().and_then(|value| value.as_name().ok()),
_ => None,
};
if let Some(blend) = blend {
self.state.blend_mode = pdf_blend_mode(blend);
}
}
soft_mask = dictionary.get(b"SMask").ok().cloned();
break;
}
if let Some(soft_mask) = soft_mask {
self.apply_soft_mask(&soft_mask)?;
}
Ok(())
}
fn apply_soft_mask(&mut self, object: &Object) -> Result<()> {
let (_, object) = self.document.dereference(object)?;
if object.as_name().ok() == Some(b"None") {
self.state.mask_id = None;
return Ok(());
}
let dictionary = object.as_dict()?;
let mask_type = match dictionary
.get(b"S")
.and_then(Object::as_name)
.unwrap_or(b"Luminosity")
{
b"Alpha" => "alpha",
_ => "luminance",
};
let group_object = dictionary
.get(b"G")
.map_err(|_| Error::InvalidInput("PDF soft mask dictionary has no /G form".into()))?;
let (group_id, group_object) = self.document.dereference(group_object)?;
let group_stream = group_object.as_stream()?.clone();
if group_stream
.dict
.get(b"Subtype")
.and_then(Object::as_name)
.unwrap_or_default()
!= b"Form"
{
return Err(Error::InvalidInput(
"PDF soft mask /G is not a Form XObject".into(),
));
}
let form_matrix = group_stream
.dict
.get(b"Matrix")
.and_then(Object::as_array)
.ok()
.and_then(|values| matrix_operands(values))
.unwrap_or(IDENTITY);
let resources = group_stream
.dict
.get_deref(b"Resources", self.document)
.and_then(Object::as_dict)
.ok()
.cloned();
let group_dictionary = group_stream
.dict
.get_deref(b"Group", self.document)
.and_then(Object::as_dict)
.ok();
let group_color_space = group_dictionary
.and_then(|group| group.get(b"CS").ok())
.cloned();
let form_bbox = group_stream
.dict
.get(b"BBox")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 4))
.filter(|values| values.len() == 4);
let content = group_stream.decompressed_content_with_limit(self.content_limit)?;
let decoded = decode_content_operations(&content, self.content_limit, self.page)?;
let mut mask_page = Page::new(
self.page.number,
self.page.width,
self.page.height,
"pdf-soft-mask",
);
let mut mask_fonts = self.fonts.clone();
mask_fonts.extend(build_resource_font_decoders(
self.document,
resources.as_ref(),
self.content_limit,
&mut mask_page,
));
let mut mask_interpreter = Interpreter {
document: self.document,
page: &mut mask_page,
page_matrix: self.page_matrix,
page_id: self.page_id,
fonts: mask_fonts,
resources: resources.into_iter().collect(),
state: GraphicsState {
ctm: compose(self.state.ctm, form_matrix),
..GraphicsState::default()
},
content_base_ctm: compose(self.state.ctm, form_matrix),
stack: Vec::new(),
path: PathBuilder::default(),
pending_clip_rule: None,
text: TextState::default(),
node_counter: self.node_counter,
clip_counter: self.clip_counter,
mask_counter: self.mask_counter,
compatibility_depth: self.compatibility_depth,
visited_forms: self.visited_forms.clone(),
visited_patterns: self.visited_patterns.clone(),
mesh_output_count: Rc::clone(&self.mesh_output_count),
hidden_ocgs: self.hidden_ocgs.clone(),
marked_content_hidden: false,
marked_content_stack: Vec::new(),
content_limit: self.content_limit,
outline_embedded_pdf_text: self.outline_embedded_pdf_text,
};
if let Some(group_id) = group_id {
mask_interpreter.visited_forms.insert(group_id);
}
mask_interpreter.interpret(&decoded, 1)?;
self.node_counter = mask_interpreter.node_counter;
self.clip_counter = mask_interpreter.clip_counter;
self.mask_counter = mask_interpreter.mask_counter;
drop(mask_interpreter);
self.mask_counter += 1;
let mask_id = format!("pdf-mask-{}-{}", self.page.number, self.mask_counter);
let mask_transform = compose(self.page_matrix, compose(self.state.ctm, form_matrix));
let mut mask_nodes = mask_page.nodes;
if mask_type == "luminance"
&& let Ok(backdrop) = dictionary.get(b"BC").and_then(Object::as_array)
{
let components = numbers(backdrop, usize::MAX);
let color = components_to_color(self.document, group_color_space.as_ref(), &components);
let bbox = form_bbox
.as_ref()
.map_or([0.0, 0.0, self.page.width, self.page.height], |bbox| {
[bbox[0], bbox[1], bbox[2], bbox[3]]
});
mask_nodes.insert(
0,
Node::Path {
id: format!("{mask_id}-backdrop"),
d: rectangle_path(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]),
fill_rule: "nonzero".into(),
fill: Paint::solid(color),
stroke: Stroke::default(),
transform: mask_transform,
clip_id: None,
meta: SourceMeta {
kind: "mask-backdrop".into(),
..SourceMeta::default()
},
},
);
}
if let Some(bbox) = form_bbox {
self.clip_counter += 1;
let clip_id = format!("pdf-clip-{}-{}", self.page.number, self.clip_counter);
mask_page.clips.push(crate::ir::ClipPath {
id: clip_id.clone(),
d: rectangle_path(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]),
transform: mask_transform,
fill_rule: "nonzero".into(),
parent_id: None,
additional_paths: Vec::new(),
});
mask_nodes = vec![Node::Group {
id: format!("{mask_id}-group"),
nodes: mask_nodes,
transform: IDENTITY,
opacity: 1.0,
clip_id: Some(clip_id),
meta: SourceMeta {
kind: "soft-mask-group".into(),
..SourceMeta::default()
},
}];
}
self.page.clips.extend(mask_page.clips);
self.page.masks.extend(mask_page.masks);
let transfer_values = dictionary.get(b"TR").ok().map_or_else(
|| Some(Vec::new()),
|transfer| soft_mask_transfer_values(self.document, transfer),
);
if dictionary.get(b"TR").is_ok() && transfer_values.is_none() {
self.page
.warn("PDF soft-mask transfer function /TR is unsupported");
}
self.page.masks.push(crate::ir::MaskDefinition {
id: mask_id.clone(),
mask_type: mask_type.into(),
nodes: mask_nodes,
transfer_values: transfer_values.unwrap_or_default(),
});
for warning in mask_page.warnings {
self.page.warn(format!("soft mask: {warning}"));
}
self.state.mask_id = Some(mask_id);
Ok(())
}
}
fn image_codec_content(stream: &Stream, content_limit: usize) -> Result<Vec<u8>> {
let filters = stream.filters().unwrap_or_default();
if filters.len() <= 1 {
return Ok(stream.content.clone());
}
let mut leading = stream.clone();
leading.dict.set(
"Filter",
Object::Array(
filters[..filters.len() - 1]
.iter()
.map(|filter| Object::Name(filter.to_vec()))
.collect(),
),
);
Ok(leading.decompressed_content_with_limit(content_limit)?)
}
fn decode_pdf_jpeg_content(
stream: &Stream,
expected_width: usize,
expected_height: usize,
content_limit: usize,
) -> Result<Vec<u8>> {
let content = image_codec_content(stream, content_limit)?;
if content.len() > content_limit {
return Err(Error::LimitExceeded(format!(
"PDF JPEG stream exceeds {content_limit} bytes"
)));
}
let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(content.as_slice()));
decoder
.read_info()
.map_err(|error| Error::InvalidInput(format!("cannot read PDF JPEG metadata: {error}")))?;
let info = decoder
.info()
.ok_or_else(|| Error::InvalidInput("PDF JPEG has no image metadata".into()))?;
let width = usize::from(info.width);
let height = usize::from(info.height);
if width != expected_width || height != expected_height {
return Err(Error::InvalidInput(format!(
"PDF JPEG is {width}x{height}; expected {expected_width}x{expected_height}"
)));
}
let decoded_bytes = width
.checked_mul(height)
.and_then(|pixels| pixels.checked_mul(info.pixel_format.pixel_bytes()))
.ok_or_else(|| Error::LimitExceeded("PDF JPEG decoded size overflow".into()))?;
if decoded_bytes > content_limit {
return Err(Error::LimitExceeded(format!(
"PDF JPEG expands to {decoded_bytes} bytes; limit is {content_limit}"
)));
}
let pixels = decoder
.decode()
.map_err(|error| Error::InvalidInput(format!("cannot decode PDF JPEG: {error}")))?;
Ok(match info.pixel_format {
jpeg_decoder::PixelFormat::L8 | jpeg_decoder::PixelFormat::RGB24 => pixels,
jpeg_decoder::PixelFormat::CMYK32 => {
pixels.into_iter().map(|sample| 255 - sample).collect()
}
jpeg_decoder::PixelFormat::L16 => pixels.chunks_exact(2).map(|sample| sample[0]).collect(),
})
}
fn decode_soft_mask_content(
document: &Document,
mask: &Stream,
expected_width: usize,
expected_height: usize,
content_limit: usize,
) -> Result<Vec<u8>> {
let filters = mask.filters().unwrap_or_default();
if !filters.iter().any(|filter| *filter == b"DCTDecode") {
return decode_predicted_content(document, mask, content_limit);
}
let content = image_codec_content(mask, content_limit)?;
if content.len() > content_limit {
return Err(Error::LimitExceeded(format!(
"PDF JPEG soft mask stream exceeds {content_limit} bytes"
)));
}
let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(content.as_slice()));
decoder.read_info().map_err(|error| {
Error::InvalidInput(format!("cannot read PDF JPEG soft mask metadata: {error}"))
})?;
let info = decoder
.info()
.ok_or_else(|| Error::InvalidInput("PDF JPEG soft mask has no image metadata".into()))?;
let width = usize::from(info.width);
let height = usize::from(info.height);
if width != expected_width || height != expected_height {
return Err(Error::InvalidInput(format!(
"PDF JPEG soft mask is {width}x{height}; expected {expected_width}x{expected_height}"
)));
}
let pixel_count = width
.checked_mul(height)
.ok_or_else(|| Error::LimitExceeded("PDF JPEG soft mask dimensions overflow".into()))?;
let decoded_bytes = pixel_count
.checked_mul(info.pixel_format.pixel_bytes())
.ok_or_else(|| Error::LimitExceeded("PDF JPEG soft mask size overflow".into()))?;
if decoded_bytes > content_limit {
return Err(Error::LimitExceeded(format!(
"PDF JPEG soft mask expands to {decoded_bytes} bytes; limit is {content_limit}"
)));
}
let pixels = decoder.decode().map_err(|error| {
Error::InvalidInput(format!("cannot decode PDF JPEG soft mask: {error}"))
})?;
let alpha = match info.pixel_format {
jpeg_decoder::PixelFormat::L8 => pixels,
jpeg_decoder::PixelFormat::L16 => pixels.chunks_exact(2).map(|sample| sample[0]).collect(),
jpeg_decoder::PixelFormat::RGB24 => pixels
.chunks_exact(3)
.map(|sample| {
((u16::from(sample[0]) * 54
+ u16::from(sample[1]) * 183
+ u16::from(sample[2]) * 19)
>> 8) as u8
})
.collect(),
jpeg_decoder::PixelFormat::CMYK32 => cmyk_samples_to_rgb(&pixels)
.chunks_exact(3)
.map(|sample| {
((u16::from(sample[0]) * 54
+ u16::from(sample[1]) * 183
+ u16::from(sample[2]) * 19)
>> 8) as u8
})
.collect(),
};
if alpha.len() != pixel_count {
return Err(Error::InvalidInput(format!(
"PDF JPEG soft mask decoded to {} samples; expected {pixel_count}",
alpha.len()
)));
}
Ok(alpha)
}
fn build_font_decoders(
document: &Document,
page_id: ObjectId,
content_limit: usize,
page: &mut Page,
) -> Result<HashMap<Vec<u8>, FontDecoder>> {
let mut result = HashMap::new();
for (name, dictionary) in document.get_page_fonts(page_id)? {
result.insert(
name.clone(),
build_font_decoder(document, &name, dictionary, content_limit, page),
);
}
Ok(result)
}
fn build_resource_font_decoders(
document: &Document,
resources: Option<&Dictionary>,
content_limit: usize,
page: &mut Page,
) -> HashMap<Vec<u8>, FontDecoder> {
let Some(fonts) = resources.and_then(|resources| {
resources
.get_deref(b"Font", document)
.and_then(Object::as_dict)
.ok()
}) else {
return HashMap::new();
};
fonts
.iter()
.filter_map(|(name, object)| {
document
.dereference(object)
.ok()
.and_then(|(_, object)| object.as_dict().ok())
.map(|dictionary| {
(
name.clone(),
build_font_decoder(document, name, dictionary, content_limit, page),
)
})
})
.collect()
}
fn build_font_decoder(
document: &Document,
name: &[u8],
dictionary: &Dictionary,
content_limit: usize,
page: &mut Page,
) -> FontDecoder {
let base_font = dictionary
.get(b"BaseFont")
.and_then(Object::as_name)
.map(|name| String::from_utf8_lossy(name).into_owned())
.unwrap_or_default();
let family = dictionary
.get(b"BaseFont")
.and_then(Object::as_name)
.map(clean_font_name)
.unwrap_or_else(|_| "Arial, 'Hiragino Sans', 'Yu Gothic', sans-serif".into());
let lowercase_family = family.to_ascii_lowercase();
let bold = lowercase_family.contains("bold") || lowercase_family.contains("black");
let italic = lowercase_family.contains("italic") || lowercase_family.contains("oblique");
let subtype = dictionary
.get(b"Subtype")
.and_then(Object::as_name)
.unwrap_or_default();
let descriptor = pdf_font_descriptor(document, dictionary, subtype);
let embedded = descriptor.is_some_and(|descriptor| {
descriptor.get(b"FontFile").is_ok()
|| descriptor.get(b"FontFile2").is_ok()
|| descriptor.get(b"FontFile3").is_ok()
});
let font_data = descriptor.and_then(|descriptor| {
[
b"FontFile".as_slice(),
b"FontFile2".as_slice(),
b"FontFile3".as_slice(),
]
.into_iter()
.find_map(|key| {
descriptor
.get_deref(key, document)
.and_then(Object::as_stream)
.ok()
.and_then(|stream| stream.decompressed_content_with_limit(content_limit).ok())
})
.map(Arc::<[u8]>::from)
});
let requires_outline = embedded && !is_browser_font_family(&family);
let glyph_names = pdf_encoding_glyph_names(document, dictionary);
let fallback_kind = if subtype == b"Type0" {
FontFallback::Utf16Be
} else {
FontFallback::OneByte
};
let mut unicode_map = dictionary
.get_deref(b"ToUnicode", document)
.and_then(Object::as_stream)
.ok()
.and_then(|stream| stream.decompressed_content_with_limit(content_limit).ok())
.map(|bytes| parse_to_unicode_cmap(&bytes))
.unwrap_or_default();
if unicode_map.is_empty()
&& subtype != b"Type0"
&& let Ok(encoding) = dictionary.get_font_encoding(document)
{
for code in 0u8..=u8::MAX {
if let Ok(value) = encoding.bytes_to_string(&[code])
&& !value.is_empty()
{
unicode_map.insert(vec![code], value);
}
}
}
let mut code_lengths = unicode_map.keys().map(Vec::len).collect::<Vec<_>>();
code_lengths.sort_unstable_by(|left, right| right.cmp(left));
code_lengths.dedup();
let (widths, default_width) =
font_widths(document, dictionary, subtype, &glyph_names, &base_font);
let type3 = (subtype == b"Type3")
.then(|| build_type3_font(document, dictionary, &glyph_names))
.flatten()
.map(Arc::new);
if unicode_map.is_empty() && subtype == b"Type0" {
page.warn(format!(
"Type0 font {} has no usable ToUnicode map; text may require replacement glyphs",
String::from_utf8_lossy(name)
));
}
if subtype == b"Type0"
&& let Ok(encoding_obj) = dictionary.get_deref(b"Encoding", document)
{
let encoding_name = match encoding_obj {
Object::Name(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
Object::String(bytes, _) => Some(String::from_utf8_lossy(bytes).into_owned()),
_ => None,
};
if let Some(encoding_name) = encoding_name
&& encoding_name != "Identity-H"
&& encoding_name != "Identity-V"
{
page.warn(format!(
"Type0 font {} uses non-Identity encoding {encoding_name}; text positioning may be compromised",
String::from_utf8_lossy(name)
));
}
}
let identity_cid = subtype == b"Type0"
&& dictionary
.get_deref(b"Encoding", document)
.ok()
.and_then(|encoding| Object::as_name(encoding).ok())
.is_some_and(|encoding_name| {
encoding_name == b"Identity-H" || encoding_name == b"Identity-V"
});
let cid_to_gid_map = (subtype == b"Type0")
.then(|| {
dictionary
.get_deref(b"DescendantFonts", document)
.and_then(Object::as_array)
.ok()
.and_then(|array| array.first())
.and_then(|object| document.dereference(object).ok().map(|(_, value)| value))
.and_then(|object| object.as_dict().ok())
.and_then(|descendant| descendant.get_deref(b"CIDToGIDMap", document).ok())
.and_then(|object| Object::as_stream(object).ok())
.and_then(|stream| stream.decompressed_content_with_limit(content_limit).ok())
.map(Arc::<[u8]>::from)
})
.flatten();
FontDecoder {
family,
bold,
italic,
requires_outline,
font_data,
glyph_names,
unicode_map,
code_lengths,
fallback_kind,
widths,
default_width,
type3,
type1: Type1Cache::default(),
identity_cid,
cid_to_gid_map,
}
}
fn build_type3_font(
document: &Document,
dictionary: &Dictionary,
glyph_names: &HashMap<u8, String>,
) -> Option<Type3Font> {
let char_procedures = dictionary
.get_deref(b"CharProcs", document)
.and_then(Object::as_dict)
.ok()?;
let mut glyphs = HashMap::new();
for (code, name) in glyph_names {
if let Ok(object) = char_procedures.get(name.as_bytes())
&& let Ok((_, object)) = document.dereference(object)
&& let Ok(stream) = object.as_stream()
{
glyphs.insert(*code, stream.clone());
}
}
if glyphs.is_empty() {
return None;
}
let font_matrix = dictionary
.get(b"FontMatrix")
.and_then(Object::as_array)
.ok()
.and_then(|values| matrix_operands(values))
.unwrap_or([0.001, 0.0, 0.0, 0.001, 0.0, 0.0]);
let resources = dictionary
.get_deref(b"Resources", document)
.and_then(Object::as_dict)
.ok()
.cloned();
Some(Type3Font {
font_matrix,
resources,
glyphs,
})
}
fn font_widths(
document: &Document,
dictionary: &Dictionary,
subtype: &[u8],
glyph_names: &HashMap<u8, String>,
base_font: &str,
) -> (HashMap<u32, f64>, f64) {
if subtype == b"Type0" {
let descendant = dictionary
.get_deref(b"DescendantFonts", document)
.and_then(Object::as_array)
.ok()
.and_then(|array| array.first())
.and_then(|object| document.dereference(object).ok().map(|(_, value)| value))
.and_then(|object| object.as_dict().ok());
let Some(descendant) = descendant else {
return (HashMap::new(), 1_000.0);
};
let default_width = descendant
.get(b"DW")
.ok()
.and_then(|value| number(Some(value)))
.unwrap_or(1_000.0);
let Some(array) = descendant
.get_deref(b"W", document)
.and_then(Object::as_array)
.ok()
else {
return (HashMap::new(), default_width);
};
let mut widths = HashMap::new();
let mut position = 0usize;
while position < array.len() {
let Some(first_code) =
integer(array.get(position)).and_then(|value| u32::try_from(value).ok())
else {
break;
};
position += 1;
let Some(next) = array.get(position) else {
break;
};
if let Ok(values) = next.as_array() {
for (offset, value) in values.iter().enumerate() {
if let Some(width) = number(Some(value)) {
widths.insert(first_code.saturating_add(offset as u32), width);
}
}
position += 1;
} else {
let Some(last_code) =
integer(Some(next)).and_then(|value| u32::try_from(value).ok())
else {
break;
};
let Some(width) = array
.get(position + 1)
.and_then(|value| number(Some(value)))
else {
break;
};
if last_code >= first_code && last_code - first_code <= 65_536 {
for code in first_code..=last_code {
widths.insert(code, width);
}
}
position += 2;
}
}
return (widths, default_width);
}
let first = dictionary
.get(b"FirstChar")
.ok()
.and_then(|value| integer(Some(value)))
.and_then(|value| u32::try_from(value).ok())
.unwrap_or(0);
let widths = dictionary
.get_deref(b"Widths", document)
.and_then(Object::as_array)
.ok()
.map(|array| {
array
.iter()
.enumerate()
.filter_map(|(offset, value)| {
number(Some(value)).map(|width| (first.saturating_add(offset as u32), width))
})
.collect::<HashMap<_, _>>()
})
.unwrap_or_default();
let default_width = dictionary
.get_deref(b"FontDescriptor", document)
.and_then(Object::as_dict)
.and_then(|descriptor| descriptor.get(b"MissingWidth"))
.ok()
.and_then(|value| number(Some(value)))
.unwrap_or(500.0);
if !widths.is_empty() {
return (widths, default_width);
}
let Some(metrics) = standard_metrics(base_font) else {
return (widths, default_width);
};
let widths = standard_encoding_glyph_names(glyph_names)
.filter_map(|(code, glyph)| metrics.width(glyph).map(|width| (u32::from(code), width)))
.collect::<HashMap<_, _>>();
(widths, default_width)
}
fn standard_encoding_glyph_names(
glyph_names: &HashMap<u8, String>,
) -> impl Iterator<Item = (u8, &str)> {
let standard = pdf_named_encoding(b"StandardEncoding");
(0u8..=u8::MAX).filter_map(move |code| {
let glyph = match glyph_names.get(&code) {
Some(name) => name.as_str(),
None => standard.get(usize::from(code)).copied()?,
};
(glyph != ".notdef").then_some((code, glyph))
})
}
fn pdf_font_descriptor<'a>(
document: &'a Document,
dictionary: &'a Dictionary,
subtype: &[u8],
) -> Option<&'a Dictionary> {
if subtype == b"Type0" {
return dictionary
.get_deref(b"DescendantFonts", document)
.and_then(Object::as_array)
.ok()
.and_then(|array| array.first())
.and_then(|object| document.dereference(object).ok().map(|(_, value)| value))
.and_then(|object| object.as_dict().ok())
.and_then(|descendant| {
descendant
.get_deref(b"FontDescriptor", document)
.and_then(Object::as_dict)
.ok()
});
}
dictionary
.get_deref(b"FontDescriptor", document)
.and_then(Object::as_dict)
.ok()
}
fn pdf_encoding_glyph_names(document: &Document, dictionary: &Dictionary) -> HashMap<u8, String> {
let Some(encoding) = dictionary
.get(b"Encoding")
.ok()
.and_then(|value| document.dereference(value).ok().map(|(_, value)| value))
.cloned()
else {
return HashMap::new();
};
let base_name = match &encoding {
Object::Name(name) => name.as_slice(),
Object::Dictionary(dictionary) => dictionary
.get(b"BaseEncoding")
.and_then(Object::as_name)
.unwrap_or(b"StandardEncoding"),
_ => b"StandardEncoding",
};
let mut result = pdf_named_encoding(base_name)
.iter()
.enumerate()
.filter(|(_, name)| **name != ".notdef")
.map(|(code, name)| (code as u8, (*name).to_owned()))
.collect::<HashMap<_, _>>();
let Some(differences) = encoding.as_dict().ok().and_then(|dictionary| {
dictionary
.get(b"Differences")
.and_then(Object::as_array)
.ok()
}) else {
return result;
};
let mut code = 0u16;
for item in differences {
match item {
Object::Integer(value) => {
code = u16::try_from(*value).unwrap_or(0).min(255);
}
Object::Name(name) => {
result.insert(code as u8, String::from_utf8_lossy(name).into_owned());
code = code.saturating_add(1).min(255);
}
_ => {}
}
}
result
}
fn pdf_named_encoding(name: &[u8]) -> &'static [&'static str; 256] {
match name {
b"MacRomanEncoding" => &stet_fonts::encoding::MACROMAN_ENCODING,
b"WinAnsiEncoding" => &stet_fonts::encoding::WINANSI_ENCODING,
b"SymbolEncoding" => &stet_fonts::encoding::SYMBOL_ENCODING,
b"ZapfDingbatsEncoding" => &stet_fonts::encoding::ZAPFDINGBATS_ENCODING,
_ => &stet_fonts::encoding::STANDARD_ENCODING,
}
}
fn parse_to_unicode_cmap(bytes: &[u8]) -> HashMap<Vec<u8>, String> {
let text = String::from_utf8_lossy(bytes);
let mut result = HashMap::new();
let mut mode = "";
for line in text.lines() {
let trimmed = line.trim();
if trimmed.ends_with("beginbfchar") {
mode = "bfchar";
continue;
}
if trimmed.ends_with("endbfchar") {
mode = "";
continue;
}
if trimmed.ends_with("beginbfrange") {
mode = "bfrange";
continue;
}
if trimmed.ends_with("endbfrange") {
mode = "";
continue;
}
let tokens = hex_tokens(trimmed);
if mode == "bfchar" && tokens.len() >= 2 {
if let (Some(source), Some(target)) =
(hex_bytes(&tokens[0]), unicode_from_hex(&tokens[1]))
{
result.insert(source, target);
}
} else if mode == "bfrange" && tokens.len() >= 3 {
let (Some(start), Some(end)) = (hex_bytes(&tokens[0]), hex_bytes(&tokens[1])) else {
continue;
};
if start.len() != end.len() || start.len() > 4 {
continue;
}
let start_value = bytes_to_u32(&start);
let end_value = bytes_to_u32(&end);
if end_value < start_value || end_value - start_value > 65_536 {
continue;
}
if tokens.len() == 3 {
if let Some(target_bytes) = hex_bytes(&tokens[2]) {
let target_value = bytes_to_u32(&target_bytes);
for offset in 0..=end_value - start_value {
let source = u32_to_bytes(start_value + offset, start.len());
let target = u32_to_bytes(target_value + offset, target_bytes.len());
if let Some(unicode) = unicode_from_bytes(&target) {
result.insert(source, unicode);
}
}
}
} else {
for (offset, token) in tokens.iter().skip(2).enumerate() {
if start_value + offset as u32 > end_value {
break;
}
if let Some(unicode) = unicode_from_hex(token) {
result.insert(
u32_to_bytes(start_value + offset as u32, start.len()),
unicode,
);
}
}
}
}
}
result
}
fn hex_tokens(line: &str) -> Vec<String> {
let mut result = Vec::new();
let mut start = None;
for (index, character) in line.char_indices() {
if character == '<' {
start = Some(index + 1);
} else if character == '>'
&& let Some(begin) = start.take()
{
result.push(line[begin..index].to_owned());
}
}
result
}
fn hex_bytes(value: &str) -> Option<Vec<u8>> {
if !value.len().is_multiple_of(2) || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
(0..value.len())
.step_by(2)
.map(|index| u8::from_str_radix(&value[index..index + 2], 16).ok())
.collect()
}
fn unicode_from_hex(value: &str) -> Option<String> {
unicode_from_bytes(&hex_bytes(value)?)
}
fn unicode_from_bytes(bytes: &[u8]) -> Option<String> {
if !bytes.len().is_multiple_of(2) {
return String::from_utf8(bytes.to_vec()).ok();
}
let units = bytes
.chunks_exact(2)
.map(|pair| u16::from_be_bytes([pair[0], pair[1]]));
Some(
char::decode_utf16(units)
.map(|item| item.unwrap_or('\u{fffd}'))
.collect(),
)
}
fn bytes_to_u32(bytes: &[u8]) -> u32 {
bytes.iter().fold(0u32, |value, byte| {
value.saturating_mul(256).saturating_add(u32::from(*byte))
})
}
fn u32_to_bytes(value: u32, length: usize) -> Vec<u8> {
value.to_be_bytes()[4usize.saturating_sub(length)..].to_vec()
}
fn evaluate_pdf_function(
document: &Document,
object: &Object,
input: f64,
depth: usize,
) -> Option<Vec<f64>> {
evaluate_pdf_function_inputs(document, object, &[input], depth)
}
fn soft_mask_transfer_values(document: &Document, object: &Object) -> Option<Vec<f64>> {
let (_, object) = document.dereference(object).ok()?;
if matches!(object, Object::Name(name) if matches!(name.as_slice(), b"Identity" | b"Default")) {
return Some(Vec::new());
}
(0..=32)
.map(|index| {
evaluate_pdf_function(document, object, index as f64 / 32.0, 0)
.and_then(|values| values.first().copied())
.filter(|value| value.is_finite())
.map(|value| value.clamp(0.0, 1.0))
})
.collect()
}
fn evaluate_pdf_function_inputs(
document: &Document,
object: &Object,
inputs: &[f64],
depth: usize,
) -> Option<Vec<f64>> {
if depth > 16 {
return None;
}
let (_, object) = document.dereference(object).ok()?;
if let Object::Array(functions) = object {
return functions
.iter()
.map(|function| {
evaluate_pdf_function_inputs(document, function, inputs, depth + 1)
.and_then(|values| values.first().copied())
})
.collect();
}
let dictionary = match object {
Object::Dictionary(dictionary) => dictionary,
Object::Stream(stream) => &stream.dict,
_ => return None,
};
let domain = dictionary
.get(b"Domain")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX))
.filter(|values| values.len() >= inputs.len() * 2)?;
let inputs = inputs
.iter()
.enumerate()
.map(|(index, input)| {
input.clamp(
domain[index * 2].min(domain[index * 2 + 1]),
domain[index * 2].max(domain[index * 2 + 1]),
)
})
.collect::<Vec<_>>();
let mut output = match dictionary
.get(b"FunctionType")
.and_then(Object::as_i64)
.ok()?
{
0 => {
let Object::Stream(stream) = object else {
return None;
};
evaluate_sampled_function(stream, dictionary, &domain, &inputs)?
}
2 => {
let input = *inputs.first()?;
let c0 = dictionary
.get(b"C0")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX))
.unwrap_or_else(|| vec![0.0]);
let c1 = dictionary
.get(b"C1")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX))
.unwrap_or_else(|| vec![1.0]);
let exponent = dictionary
.get(b"N")
.ok()
.and_then(|value| number(Some(value)))
.unwrap_or(1.0);
let denominator = domain[1] - domain[0];
let normalized = if denominator.abs() <= 1e-15 {
0.0
} else {
((input - domain[0]) / denominator).clamp(0.0, 1.0)
};
let factor = normalized.powf(exponent);
let count = c0.len().max(c1.len());
(0..count)
.map(|index| {
let first = c0.get(index).copied().unwrap_or(0.0);
let second = c1.get(index).copied().unwrap_or(first);
first + factor * (second - first)
})
.collect()
}
3 => {
let input = *inputs.first()?;
let functions = dictionary
.get(b"Functions")
.and_then(Object::as_array)
.ok()?;
if functions.is_empty() {
return None;
}
let bounds = dictionary
.get(b"Bounds")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX))
.unwrap_or_default();
let encode = dictionary
.get(b"Encode")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX))
.unwrap_or_else(|| vec![0.0, 1.0]);
let segment = bounds
.iter()
.position(|bound| input < *bound)
.unwrap_or(functions.len() - 1)
.min(functions.len() - 1);
let lower = if segment == 0 {
domain[0]
} else {
bounds.get(segment - 1).copied().unwrap_or(domain[0])
};
let upper = bounds.get(segment).copied().unwrap_or(domain[1]);
let encoded_start = encode.get(segment * 2).copied().unwrap_or(0.0);
let encoded_end = encode.get(segment * 2 + 1).copied().unwrap_or(1.0);
let mapped = if (upper - lower).abs() <= 1e-15 {
encoded_start
} else {
encoded_start + (input - lower) / (upper - lower) * (encoded_end - encoded_start)
};
evaluate_pdf_function_inputs(document, &functions[segment], &[mapped], depth + 1)?
}
4 => {
let Object::Stream(stream) = object else {
return None;
};
const MAX_CALCULATOR_BYTES: usize = 1024 * 1024;
let source = stream
.decompressed_content_with_limit(MAX_CALCULATOR_BYTES)
.ok()?;
evaluate_calculator_function(&source, &inputs)?
}
_ => return None,
};
if let Ok(range) = dictionary.get(b"Range").and_then(Object::as_array) {
let range = numbers(range, usize::MAX);
for (index, value) in output.iter_mut().enumerate() {
if let (Some(minimum), Some(maximum)) = (range.get(index * 2), range.get(index * 2 + 1))
{
*value = value.clamp(minimum.min(*maximum), minimum.max(*maximum));
}
}
}
Some(output)
}
fn evaluate_sampled_function(
stream: &Stream,
dictionary: &Dictionary,
domain: &[f64],
inputs: &[f64],
) -> Option<Vec<f64>> {
if dictionary
.get(b"Order")
.ok()
.and_then(|value| integer(Some(value)))
.unwrap_or(1)
!= 1
{
return None;
}
let sizes = dictionary
.get(b"Size")
.and_then(Object::as_array)
.ok()?
.iter()
.map(|value| usize::try_from(integer(Some(value))?).ok())
.collect::<Option<Vec<_>>>()?;
if sizes.len() != inputs.len() || sizes.is_empty() || sizes.len() > 8 || sizes.contains(&0) {
return None;
}
let bits_per_sample = dictionary
.get(b"BitsPerSample")
.ok()
.and_then(|value| integer(Some(value)))
.and_then(|value| usize::try_from(value).ok())?;
if !matches!(bits_per_sample, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) {
return None;
}
let range = dictionary
.get(b"Range")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX))?;
if range.is_empty() || !range.len().is_multiple_of(2) {
return None;
}
let output_count = range.len() / 2;
let encode = dictionary
.get(b"Encode")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX))
.filter(|values| values.len() >= inputs.len() * 2)
.unwrap_or_else(|| {
sizes
.iter()
.flat_map(|size| [0.0, size.saturating_sub(1) as f64])
.collect()
});
let decode = dictionary
.get(b"Decode")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX))
.filter(|values| values.len() >= output_count * 2)
.unwrap_or_else(|| range.clone());
let mut encoded = Vec::with_capacity(inputs.len());
for index in 0..inputs.len() {
let denominator = domain[index * 2 + 1] - domain[index * 2];
let value = if denominator.abs() <= 1e-15 {
encode[index * 2]
} else {
encode[index * 2]
+ (inputs[index] - domain[index * 2]) / denominator
* (encode[index * 2 + 1] - encode[index * 2])
};
encoded.push(value.clamp(0.0, sizes[index].saturating_sub(1) as f64));
}
let sample_count = sizes
.iter()
.try_fold(1usize, |count, size| count.checked_mul(*size))?;
let required_bits = sample_count
.checked_mul(output_count)?
.checked_mul(bits_per_sample)?;
const MAX_SAMPLED_FUNCTION_BYTES: usize = 64 * 1024 * 1024;
let source = stream
.decompressed_content_with_limit(MAX_SAMPLED_FUNCTION_BYTES)
.ok()?;
if required_bits > source.len().checked_mul(8)? {
return None;
}
let largest_sample = (1u64 << bits_per_sample) - 1;
let corner_count = 1usize.checked_shl(u32::try_from(inputs.len()).ok()?)?;
let mut output = vec![0.0; output_count];
for corner in 0..corner_count {
let mut table_index = 0usize;
let mut stride = 1usize;
let mut weight = 1.0;
for dimension in 0..inputs.len() {
let lower = encoded[dimension].floor() as usize;
let upper = (lower + 1).min(sizes[dimension] - 1);
let fraction = encoded[dimension] - lower as f64;
let use_upper = corner & (1usize << dimension) != 0;
let coordinate = if use_upper { upper } else { lower };
weight *= if use_upper { fraction } else { 1.0 - fraction };
table_index = table_index.checked_add(coordinate.checked_mul(stride)?)?;
stride = stride.checked_mul(sizes[dimension])?;
}
if weight <= 0.0 {
continue;
}
for component in 0..output_count {
let sample_index = table_index
.checked_mul(output_count)?
.checked_add(component)?;
let raw = read_packed_sample(&source, sample_index, bits_per_sample)?;
let normalized = raw as f64 / largest_sample as f64;
let decoded = decode[component * 2]
+ normalized * (decode[component * 2 + 1] - decode[component * 2]);
output[component] += decoded * weight;
}
}
Some(output)
}
fn read_packed_sample(data: &[u8], sample_index: usize, bits_per_sample: usize) -> Option<u64> {
let bit_start = sample_index.checked_mul(bits_per_sample)?;
let bit_end = bit_start.checked_add(bits_per_sample)?;
if bit_end > data.len().checked_mul(8)? {
return None;
}
let mut value = 0u64;
for bit_position in bit_start..bit_end {
let byte = data[bit_position / 8];
let bit = (byte >> (7 - bit_position % 8)) & 1;
value = (value << 1) | u64::from(bit);
}
Some(value)
}
#[derive(Clone, Debug)]
enum CalculatorToken {
Number(f64),
Boolean(bool),
Procedure(Vec<CalculatorToken>),
Operator(String),
}
#[derive(Clone, Debug)]
enum CalculatorValue {
Number(f64),
Boolean(bool),
Procedure(Vec<CalculatorToken>),
}
fn evaluate_calculator_function(source: &[u8], inputs: &[f64]) -> Option<Vec<f64>> {
let source = std::str::from_utf8(source).ok()?;
let mut position = 0usize;
let tokens = parse_calculator_tokens(source, &mut position, false)?;
let tokens = match tokens.as_slice() {
[CalculatorToken::Procedure(body)] => body.as_slice(),
_ => tokens.as_slice(),
};
let mut stack = inputs
.iter()
.copied()
.map(CalculatorValue::Number)
.collect::<Vec<_>>();
let mut operations = 0usize;
execute_calculator_tokens(tokens, &mut stack, &mut operations, 0)?;
stack
.into_iter()
.map(|value| match value {
CalculatorValue::Number(value) if value.is_finite() => Some(value),
_ => None,
})
.collect()
}
fn parse_calculator_tokens(
source: &str,
position: &mut usize,
procedure: bool,
) -> Option<Vec<CalculatorToken>> {
let bytes = source.as_bytes();
let mut tokens = Vec::new();
while *position < bytes.len() {
while *position < bytes.len() {
if bytes[*position] == b'%' {
while *position < bytes.len() && !matches!(bytes[*position], b'\n' | b'\r') {
*position += 1;
}
} else if bytes[*position].is_ascii_whitespace() {
*position += 1;
} else {
break;
}
}
if *position >= bytes.len() {
return (!procedure).then_some(tokens);
}
match bytes[*position] {
b'{' => {
*position += 1;
tokens.push(CalculatorToken::Procedure(parse_calculator_tokens(
source, position, true,
)?));
}
b'}' => {
*position += 1;
return procedure.then_some(tokens);
}
_ => {
let start = *position;
while *position < bytes.len()
&& !bytes[*position].is_ascii_whitespace()
&& !matches!(bytes[*position], b'{' | b'}' | b'%')
{
*position += 1;
}
if start == *position {
return None;
}
let word = &source[start..*position];
let token = if word == "true" {
CalculatorToken::Boolean(true)
} else if word == "false" {
CalculatorToken::Boolean(false)
} else if let Ok(value) = word.parse::<f64>() {
CalculatorToken::Number(value)
} else {
CalculatorToken::Operator(word.to_owned())
};
tokens.push(token);
}
}
}
(!procedure).then_some(tokens)
}
fn execute_calculator_tokens(
tokens: &[CalculatorToken],
stack: &mut Vec<CalculatorValue>,
operations: &mut usize,
depth: usize,
) -> Option<()> {
const MAX_OPERATIONS: usize = 100_000;
const MAX_STACK: usize = 4_096;
if depth > 64 {
return None;
}
for token in tokens {
*operations += 1;
if *operations > MAX_OPERATIONS || stack.len() > MAX_STACK {
return None;
}
match token {
CalculatorToken::Number(value) => stack.push(CalculatorValue::Number(*value)),
CalculatorToken::Boolean(value) => stack.push(CalculatorValue::Boolean(*value)),
CalculatorToken::Procedure(body) => {
stack.push(CalculatorValue::Procedure(body.clone()));
}
CalculatorToken::Operator(operator) => {
execute_calculator_operator(operator, stack, operations, depth)?;
}
}
}
Some(())
}
fn calculator_number(stack: &mut Vec<CalculatorValue>) -> Option<f64> {
match stack.pop()? {
CalculatorValue::Number(value) => Some(value),
_ => None,
}
}
fn calculator_boolean(stack: &mut Vec<CalculatorValue>) -> Option<bool> {
match stack.pop()? {
CalculatorValue::Boolean(value) => Some(value),
_ => None,
}
}
fn calculator_procedure(stack: &mut Vec<CalculatorValue>) -> Option<Vec<CalculatorToken>> {
match stack.pop()? {
CalculatorValue::Procedure(value) => Some(value),
_ => None,
}
}
fn calculator_integer(value: f64) -> Option<i64> {
(value.is_finite() && value >= i64::MIN as f64 && value <= i64::MAX as f64)
.then_some(value.trunc() as i64)
}
fn execute_calculator_operator(
operator: &str,
stack: &mut Vec<CalculatorValue>,
operations: &mut usize,
depth: usize,
) -> Option<()> {
let unary_number = |stack: &mut Vec<CalculatorValue>, function: fn(f64) -> f64| {
let value = calculator_number(stack)?;
let result = function(value);
result
.is_finite()
.then(|| stack.push(CalculatorValue::Number(result)))
};
let binary_number = |stack: &mut Vec<CalculatorValue>, function: fn(f64, f64) -> f64| {
let second = calculator_number(stack)?;
let first = calculator_number(stack)?;
let result = function(first, second);
result
.is_finite()
.then(|| stack.push(CalculatorValue::Number(result)))
};
match operator {
"abs" => unary_number(stack, f64::abs)?,
"add" => binary_number(stack, |a, b| a + b)?,
"atan" => {
let denominator = calculator_number(stack)?;
let numerator = calculator_number(stack)?;
stack.push(CalculatorValue::Number(
numerator.atan2(denominator).to_degrees().rem_euclid(360.0),
));
}
"ceiling" => unary_number(stack, f64::ceil)?,
"cos" => unary_number(stack, |value| value.to_radians().cos())?,
"cvi" | "truncate" => unary_number(stack, f64::trunc)?,
"cvr" => unary_number(stack, |value| value)?,
"div" => binary_number(stack, |a, b| a / b)?,
"exp" => binary_number(stack, f64::powf)?,
"floor" => unary_number(stack, f64::floor)?,
"idiv" => {
let second = calculator_integer(calculator_number(stack)?)?;
let first = calculator_integer(calculator_number(stack)?)?;
stack.push(CalculatorValue::Number((first.checked_div(second)?) as f64));
}
"ln" => unary_number(stack, f64::ln)?,
"log" => unary_number(stack, f64::log10)?,
"mod" => {
let second = calculator_integer(calculator_number(stack)?)?;
let first = calculator_integer(calculator_number(stack)?)?;
stack.push(CalculatorValue::Number((first.checked_rem(second)?) as f64));
}
"mul" => binary_number(stack, |a, b| a * b)?,
"neg" => unary_number(stack, |value| -value)?,
"round" => unary_number(stack, f64::round)?,
"sin" => unary_number(stack, |value| value.to_radians().sin())?,
"sqrt" => unary_number(stack, f64::sqrt)?,
"sub" => binary_number(stack, |a, b| a - b)?,
"eq" | "ne" => {
let second = stack.pop()?;
let first = stack.pop()?;
let equal = match (first, second) {
(CalculatorValue::Number(a), CalculatorValue::Number(b)) => a == b,
(CalculatorValue::Boolean(a), CalculatorValue::Boolean(b)) => a == b,
_ => false,
};
stack.push(CalculatorValue::Boolean(if operator == "eq" {
equal
} else {
!equal
}));
}
"ge" | "gt" | "le" | "lt" => {
let second = calculator_number(stack)?;
let first = calculator_number(stack)?;
let value = match operator {
"ge" => first >= second,
"gt" => first > second,
"le" => first <= second,
_ => first < second,
};
stack.push(CalculatorValue::Boolean(value));
}
"and" | "or" | "xor" => {
let second = stack.pop()?;
let first = stack.pop()?;
let value = match (first, second) {
(CalculatorValue::Boolean(a), CalculatorValue::Boolean(b)) => {
CalculatorValue::Boolean(match operator {
"and" => a && b,
"or" => a || b,
_ => a ^ b,
})
}
(CalculatorValue::Number(a), CalculatorValue::Number(b)) => {
let a = calculator_integer(a)?;
let b = calculator_integer(b)?;
CalculatorValue::Number(match operator {
"and" => a & b,
"or" => a | b,
_ => a ^ b,
} as f64)
}
_ => return None,
};
stack.push(value);
}
"bitshift" => {
let shift = calculator_integer(calculator_number(stack)?)?;
let value = calculator_integer(calculator_number(stack)?)?;
let result = if shift >= 0 {
value.checked_shl(u32::try_from(shift).ok()?)?
} else {
value.checked_shr(u32::try_from(-shift).ok()?)?
};
stack.push(CalculatorValue::Number(result as f64));
}
"not" => match stack.pop()? {
CalculatorValue::Boolean(value) => stack.push(CalculatorValue::Boolean(!value)),
CalculatorValue::Number(value) => stack.push(CalculatorValue::Number(
(!calculator_integer(value)?) as f64,
)),
_ => return None,
},
"copy" => {
let count = usize::try_from(calculator_integer(calculator_number(stack)?)?).ok()?;
if count > stack.len() || stack.len() + count > 4_096 {
return None;
}
stack.extend_from_within(stack.len() - count..);
}
"dup" => stack.push(stack.last()?.clone()),
"exch" => {
let length = stack.len();
if length < 2 {
return None;
}
stack.swap(length - 1, length - 2);
}
"index" => {
let index = usize::try_from(calculator_integer(calculator_number(stack)?)?).ok()?;
let value = stack.get(stack.len().checked_sub(index + 1)?)?.clone();
stack.push(value);
}
"pop" => {
stack.pop()?;
}
"roll" => {
let amount = calculator_integer(calculator_number(stack)?)?;
let count = usize::try_from(calculator_integer(calculator_number(stack)?)?).ok()?;
if count > stack.len() || count == 0 {
return None;
}
let start = stack.len() - count;
stack[start..].rotate_right(amount.rem_euclid(count as i64) as usize);
}
"if" => {
let procedure = calculator_procedure(stack)?;
if calculator_boolean(stack)? {
execute_calculator_tokens(&procedure, stack, operations, depth + 1)?;
}
}
"ifelse" => {
let alternative = calculator_procedure(stack)?;
let consequent = calculator_procedure(stack)?;
let procedure = if calculator_boolean(stack)? {
consequent
} else {
alternative
};
execute_calculator_tokens(&procedure, stack, operations, depth + 1)?;
}
_ => return None,
}
Some(())
}
fn apply_gradient_extension(stops: &mut Vec<GradientStop>, extend: [bool; 2]) {
apply_gradient_extension_with_background(stops, extend, None, 0.0);
}
fn apply_gradient_extension_with_background(
stops: &mut Vec<GradientStop>,
extend: [bool; 2],
background: Option<&str>,
background_opacity: f64,
) {
const EDGE_EPSILON: f64 = 1e-6;
if !extend[0]
&& let Some(first) = stops.first().cloned()
{
if let Some(background) = background {
stops[0].color = background.into();
stops[0].opacity = background_opacity;
} else {
stops[0].opacity = 0.0;
}
stops.insert(
1,
GradientStop {
offset: EDGE_EPSILON,
..first
},
);
}
if !extend[1]
&& let Some(last) = stops.last().cloned()
{
if let Some(last_stop) = stops.last_mut() {
if let Some(background) = background {
last_stop.color = background.into();
last_stop.opacity = background_opacity;
} else {
last_stop.opacity = 0.0;
}
}
stops.insert(
stops.len().saturating_sub(1),
GradientStop {
offset: 1.0 - EDGE_EPSILON,
..last
},
);
}
}
#[derive(Clone, Copy)]
struct FunctionShadingBounds {
x0: f64,
x1: f64,
y0: f64,
y1: f64,
}
struct FunctionShadingCell {
points: [(f64, f64); 4],
components: Vec<f64>,
}
struct FunctionShadingField<'a> {
document: &'a Document,
function: &'a Object,
transform: Matrix,
}
impl FunctionShadingField<'_> {
fn sample(&self, x: f64, y: f64) -> Option<Vec<f64>> {
evaluate_pdf_function_inputs(self.document, self.function, &[x, y], 0)
}
fn point(&self, x: f64, y: f64) -> (f64, f64) {
transform_point(self.transform, x, y)
}
}
#[allow(clippy::too_many_arguments)]
fn build_function_shading_cell_nodes(
document: &Document,
function: &Object,
domain: &[f64],
transform: Matrix,
color_space: Option<&Object>,
fill_alpha: f64,
blend_mode: &str,
mask_id: &str,
alpha_is_shape: bool,
clip_id: Option<String>,
page_number: usize,
id_prefix: &str,
source_name: &str,
) -> Option<(Vec<Node>, bool)> {
let field = FunctionShadingField {
document,
function,
transform,
};
let root = FunctionShadingBounds {
x0: domain[0],
x1: domain[1],
y0: domain[2],
y1: domain[3],
};
const MAX_CELLS: usize = 100_000;
let mut cells = Vec::new();
if !adaptive_function_shading_cells(&field, root, &mut cells, MAX_CELLS) {
return None;
}
let truncated = cells.len() >= MAX_CELLS;
Some((
cells
.into_iter()
.enumerate()
.map(|(index, cell)| Node::Path {
id: format!("{id_prefix}-{page_number}-{}", index + 1),
d: quadrilateral_path(cell.points),
fill_rule: "nonzero".into(),
fill: Paint::Solid {
color: components_to_color(document, color_space, &cell.components),
opacity: fill_alpha,
},
stroke: Stroke::default(),
transform: IDENTITY,
clip_id: clip_id.clone(),
meta: SourceMeta {
kind: "function-shading-cell".into(),
source_id: format!("page:{page_number}:shading:{source_name}"),
blend_mode: blend_mode.into(),
mask_id: mask_id.into(),
alpha_is_shape,
shape_rendering: "crispEdges".into(),
..SourceMeta::default()
},
})
.collect(),
truncated,
))
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn parse_mesh_shading_triangles(
document: &Document,
shading: &Object,
dictionary: &Dictionary,
shading_type: i64,
content_limit: usize,
mesh_output_so_far: usize,
name: &[u8],
page: &mut Page,
) -> Result<Option<(Vec<MeshTriangle>, usize, Option<Object>)>> {
let Object::Stream(stream) = shading else {
page.warn(format!(
"PDF mesh shading {} has no stream data",
String::from_utf8_lossy(name)
));
return Ok(None);
};
let data = stream.decompressed_content_with_limit(content_limit)?;
let bits_per_coordinate = dictionary
.get(b"BitsPerCoordinate")
.and_then(Object::as_i64)
.unwrap_or(0);
let bits_per_component = dictionary
.get(b"BitsPerComponent")
.and_then(Object::as_i64)
.unwrap_or(0);
let bits_per_flag = dictionary
.get(b"BitsPerFlag")
.and_then(Object::as_i64)
.unwrap_or(0);
if bits_per_coordinate <= 0
|| bits_per_component <= 0
|| bits_per_coordinate > 32
|| bits_per_component > 16
{
page.warn("PDF mesh shading has invalid bit widths");
return Ok(None);
}
let color_space = dictionary.get(b"ColorSpace").ok().cloned();
let component_count = pdf_color_component_count(document, color_space.as_ref());
let decode = dictionary
.get(b"Decode")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX))
.unwrap_or_default();
let Some(decode) = complete_mesh_decode_ranges(&decode, component_count) else {
page.warn("PDF mesh shading /Decode array is incomplete");
return Ok(None);
};
let vertices_per_row = dictionary
.get(b"VerticesPerRow")
.and_then(Object::as_i64)
.unwrap_or(0)
.max(0) as usize;
let remaining_page_budget = MAX_PAGE_MESH_TRIANGLES.saturating_sub(mesh_output_so_far);
if remaining_page_budget == 0 {
return Ok(None);
}
let max_output_triangles = remaining_page_budget.min(3_000);
let (triangles, subdivisions) = if matches!(shading_type, 6 | 7) {
let (patches, has_reused_patch) = parse_patch_meshes(
&data,
shading_type,
bits_per_coordinate as usize,
bits_per_component as usize,
bits_per_flag.max(0) as usize,
component_count,
&decode,
);
if has_reused_patch {
page.warn(
"PDF patch mesh reuse flags 1-3 are detected; only independent flag-0 patches are rendered",
);
}
let divisions = ((max_output_triangles / (patches.len().max(1) * 2)) as f64)
.sqrt()
.floor()
.clamp(1.0, 64.0) as usize;
(
patches
.iter()
.flat_map(|patch| tessellate_patch(patch, divisions))
.collect(),
1,
)
} else {
let triangles = parse_mesh_triangles(
&data,
shading_type,
bits_per_coordinate as usize,
bits_per_component as usize,
bits_per_flag.max(0) as usize,
component_count,
&decode,
vertices_per_row,
);
let divisions = ((max_output_triangles / triangles.len().max(1)) as f64)
.sqrt()
.floor()
.clamp(1.0, 64.0) as usize;
(triangles, divisions)
};
if triangles.is_empty() {
page.warn(format!(
"PDF mesh shading {} produced no triangles",
String::from_utf8_lossy(name)
));
return Ok(None);
}
Ok(Some((triangles, subdivisions, color_space)))
}
#[allow(clippy::too_many_arguments)]
fn build_mesh_triangle_nodes(
document: &Document,
triangles: &[MeshTriangle],
subdivisions: usize,
color_space: Option<&Object>,
transform: Matrix,
fill_alpha: f64,
blend_mode: &str,
mask_id: &str,
alpha_is_shape: bool,
clip_id: Option<String>,
page_number: usize,
id_prefix: &str,
source_name: &str,
mesh_output_count: &Cell<usize>,
) -> Vec<Node> {
let max_output_triangles = MAX_PAGE_MESH_TRIANGLES
.saturating_sub(mesh_output_count.get())
.min(3_000);
let mut nodes = Vec::new();
let mut output_count = 0usize;
'triangles: for (triangle_index, triangle) in triangles.iter().enumerate() {
for micro in subdivide_mesh_triangle(triangle, subdivisions) {
if output_count >= max_output_triangles
|| mesh_output_count.get() >= MAX_PAGE_MESH_TRIANGLES
{
break 'triangles;
}
output_count += 1;
mesh_output_count.set(mesh_output_count.get().saturating_add(1));
let color = components_to_color(document, color_space, µ.average_components());
nodes.push(Node::Path {
id: format!("{id_prefix}-{page_number}-{triangle_index}-{output_count}"),
d: micro.path_data(),
fill_rule: "nonzero".into(),
fill: Paint::Solid {
color,
opacity: fill_alpha,
},
stroke: Stroke::default(),
transform,
clip_id: clip_id.clone(),
meta: SourceMeta {
kind: "mesh-triangle".into(),
source_id: format!("page:{page_number}:shading:{source_name}"),
blend_mode: blend_mode.into(),
mask_id: mask_id.into(),
alpha_is_shape,
shape_rendering: "crispEdges".into(),
..SourceMeta::default()
},
});
}
}
nodes
}
fn adaptive_function_shading_cells(
field: &FunctionShadingField<'_>,
root: FunctionShadingBounds,
output: &mut Vec<FunctionShadingCell>,
maximum: usize,
) -> bool {
let mut queue = std::collections::VecDeque::new();
queue.push_back((root, 0usize, None));
while let Some((bounds, depth, streak)) = queue.pop_front() {
if output.len() >= maximum {
return true;
}
if !adaptive_function_shading_cell(field, bounds, depth, streak, output, &mut queue) {
return false;
}
}
true
}
#[allow(clippy::type_complexity)]
fn adaptive_function_shading_cell(
field: &FunctionShadingField<'_>,
bounds: FunctionShadingBounds,
depth: usize,
streak: Option<(bool, u32)>,
output: &mut Vec<FunctionShadingCell>,
queue: &mut std::collections::VecDeque<(FunctionShadingBounds, usize, Option<(bool, u32)>)>,
) -> bool {
let middle_x = (bounds.x0 + bounds.x1) * 0.5;
let middle_y = (bounds.y0 + bounds.y1) * 0.5;
let coordinates = [
(bounds.x0, bounds.y0),
(middle_x, bounds.y0),
(bounds.x1, bounds.y0),
(bounds.x0, middle_y),
(middle_x, middle_y),
(bounds.x1, middle_y),
(bounds.x0, bounds.y1),
(middle_x, bounds.y1),
(bounds.x1, bounds.y1),
];
let Some(samples) = coordinates
.iter()
.map(|(x, y)| field.sample(*x, *y))
.collect::<Option<Vec<_>>>()
else {
return false;
};
let component_count = samples.iter().map(Vec::len).min().unwrap_or(0);
if component_count == 0 {
return false;
}
let maximum_range = (0..component_count)
.map(|component| {
let minimum = samples
.iter()
.map(|sample| sample[component])
.fold(f64::INFINITY, f64::min);
let maximum = samples
.iter()
.map(|sample| sample[component])
.fold(f64::NEG_INFINITY, f64::max);
maximum - minimum
})
.fold(0.0_f64, f64::max);
let interpolation_error = (0..component_count)
.map(|component| {
let bilinear = (samples[0][component]
+ samples[2][component]
+ samples[6][component]
+ samples[8][component])
* 0.25;
(samples[4][component] - bilinear).abs()
})
.fold(0.0_f64, f64::max);
let u_change = (0..component_count)
.flat_map(|component| {
[
(samples[0][component] - samples[2][component]).abs(),
(samples[3][component] - samples[5][component]).abs(),
(samples[6][component] - samples[8][component]).abs(),
]
})
.fold(0.0_f64, f64::max);
let v_change = (0..component_count)
.flat_map(|component| {
[
(samples[0][component] - samples[6][component]).abs(),
(samples[1][component] - samples[7][component]).abs(),
(samples[2][component] - samples[8][component]).abs(),
]
})
.fold(0.0_f64, f64::max);
let points = [
field.point(bounds.x0, bounds.y0),
field.point(bounds.x1, bounds.y0),
field.point(bounds.x1, bounds.y1),
field.point(bounds.x0, bounds.y1),
];
let u_length = (points[1].0 - points[0].0)
.hypot(points[1].1 - points[0].1)
.max((points[2].0 - points[3].0).hypot(points[2].1 - points[3].1));
let v_length = (points[3].0 - points[0].0)
.hypot(points[3].1 - points[0].1)
.max((points[2].0 - points[1].0).hypot(points[2].1 - points[1].1));
const RANGE_TOLERANCE: f64 = 2.0 / 255.0;
const INTERPOLATION_TOLERANCE: f64 = 0.75 / 255.0;
let at_pixel_limit = u_length <= 0.75 && v_length <= 0.75;
if depth >= 18
|| at_pixel_limit
|| (maximum_range <= RANGE_TOLERANCE && interpolation_error <= INTERPOLATION_TOLERANCE)
{
output.push(FunctionShadingCell {
points,
components: samples[4][..component_count].to_vec(),
});
return true;
}
let heuristic_split_u = if u_change != v_change {
u_change > v_change
} else {
u_length >= v_length
};
const MAX_AXIS_STREAK: u32 = 4;
let forced_switch = matches!(streak, Some((last_was_u, count))
if last_was_u == heuristic_split_u && count >= MAX_AXIS_STREAK);
let split_u = heuristic_split_u ^ forced_switch;
let next_streak = match streak {
Some((last_was_u, count)) if last_was_u == split_u => Some((split_u, count + 1)),
_ => Some((split_u, 1)),
};
if split_u {
queue.push_back((
FunctionShadingBounds {
x1: middle_x,
..bounds
},
depth + 1,
next_streak,
));
queue.push_back((
FunctionShadingBounds {
x0: middle_x,
..bounds
},
depth + 1,
next_streak,
));
} else {
queue.push_back((
FunctionShadingBounds {
y1: middle_y,
..bounds
},
depth + 1,
next_streak,
));
queue.push_back((
FunctionShadingBounds {
y0: middle_y,
..bounds
},
depth + 1,
next_streak,
));
}
true
}
#[derive(Clone, Copy)]
struct RadialBounds {
x: f64,
y: f64,
width: f64,
height: f64,
}
struct RadialCell {
bounds: RadialBounds,
components: Vec<f64>,
coverage: f64,
}
struct NoncontainedRadialField<'a> {
document: &'a Document,
function: &'a Object,
coordinates: [f64; 6],
domain: [f64; 2],
extend: [bool; 2],
inverse: Matrix,
}
impl NoncontainedRadialField<'_> {
fn sample(&self, output_x: f64, output_y: f64) -> Option<Vec<f64>> {
let (x, y) = transform_point(self.inverse, output_x, output_y);
let [x0, y0, radius0, x1, y1, radius1] = self.coordinates;
let delta_x = x1 - x0;
let delta_y = y1 - y0;
let delta_radius = radius1 - radius0;
let point_x = x - x0;
let point_y = y - y0;
let a = delta_x * delta_x + delta_y * delta_y - delta_radius * delta_radius;
let b = -2.0 * (point_x * delta_x + point_y * delta_y + radius0 * delta_radius);
let c = point_x * point_x + point_y * point_y - radius0 * radius0;
let mut roots = Vec::with_capacity(2);
if a.abs() <= 1e-15 {
if b.abs() > 1e-15 {
roots.push(-c / b);
}
} else {
let discriminant = b * b - 4.0 * a * c;
if discriminant >= -1e-12 {
let root = discriminant.max(0.0).sqrt();
roots.push((-b - root) / (2.0 * a));
roots.push((-b + root) / (2.0 * a));
}
}
let parameter = roots
.iter()
.copied()
.filter(|value| {
(*value >= -1e-9 && *value <= 1.0 + 1e-9)
|| (*value < 0.0 && self.extend[0])
|| (*value > 1.0 && self.extend[1])
})
.max_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal))?;
let parameter = parameter.clamp(0.0, 1.0);
let function_input = self.domain[0] + parameter * (self.domain[1] - self.domain[0]);
evaluate_pdf_function(self.document, self.function, function_input, 0)
}
}
fn adaptive_radial_cells(
field: &NoncontainedRadialField<'_>,
bounds: RadialBounds,
depth: usize,
output: &mut Vec<RadialCell>,
maximum: usize,
) {
if output.len() >= maximum {
return;
}
let sample_points = [
(0.0, 0.0),
(0.5, 0.0),
(1.0, 0.0),
(0.0, 0.5),
(0.5, 0.5),
(1.0, 0.5),
(0.0, 1.0),
(0.5, 1.0),
(1.0, 1.0),
];
let samples = sample_points
.iter()
.map(|(x, y)| field.sample(bounds.x + x * bounds.width, bounds.y + y * bounds.height))
.collect::<Vec<_>>();
let opaque = samples
.iter()
.filter_map(Option::as_ref)
.collect::<Vec<_>>();
if opaque.is_empty() {
return;
}
let component_count = opaque[0].len();
let mut maximum_error = 0.0_f64;
for component in 0..component_count {
let minimum = opaque
.iter()
.map(|sample| sample.get(component).copied().unwrap_or(0.0))
.fold(f64::INFINITY, f64::min);
let maximum = opaque
.iter()
.map(|sample| sample.get(component).copied().unwrap_or(0.0))
.fold(f64::NEG_INFINITY, f64::max);
maximum_error = maximum_error.max(maximum - minimum);
}
let mixed_coverage = opaque.len() != samples.len();
if depth < 5 && (mixed_coverage || maximum_error > 2.0 / 255.0) {
let half_width = bounds.width / 2.0;
let half_height = bounds.height / 2.0;
for (x_offset, y_offset) in [
(0.0, 0.0),
(half_width, 0.0),
(0.0, half_height),
(half_width, half_height),
] {
adaptive_radial_cells(
field,
RadialBounds {
x: bounds.x + x_offset,
y: bounds.y + y_offset,
width: half_width,
height: half_height,
},
depth + 1,
output,
maximum,
);
}
return;
}
let components = (0..component_count)
.map(|component| {
opaque
.iter()
.map(|sample| sample.get(component).copied().unwrap_or(0.0))
.sum::<f64>()
/ opaque.len() as f64
})
.collect();
output.push(RadialCell {
bounds,
components,
coverage: opaque.len() as f64 / samples.len() as f64,
});
}
fn inverse_matrix(matrix: Matrix) -> Option<Matrix> {
let [a, b, c, d, e, f] = matrix;
let determinant = a * d - b * c;
if determinant.abs() <= 1e-15 {
return None;
}
Some([
d / determinant,
-b / determinant,
-c / determinant,
a / determinant,
(c * f - d * e) / determinant,
(b * e - a * f) / determinant,
])
}
fn complete_mesh_decode_ranges(decode: &[f64], component_count: usize) -> Option<Vec<f64>> {
let required = 4usize.checked_add(component_count.checked_mul(2)?)?;
if decode.len() >= required {
return Some(decode[..required].to_vec());
}
if decode.len() < 6 || !(decode.len() - 4).is_multiple_of(2) {
return None;
}
let mut completed = decode.to_vec();
let last_pair = [decode[decode.len() - 2], decode[decode.len() - 1]];
while completed.len() < required {
completed.extend_from_slice(&last_pair);
}
Some(completed)
}
#[derive(Clone, Debug)]
struct MeshVertex {
x: f64,
y: f64,
components: Vec<f64>,
}
#[derive(Clone, Debug)]
struct MeshTriangle([MeshVertex; 3]);
impl MeshTriangle {
fn average_components(&self) -> Vec<f64> {
let count = self.0[0].components.len();
(0..count)
.map(|index| {
self.0
.iter()
.map(|vertex| vertex.components.get(index).copied().unwrap_or(0.0))
.sum::<f64>()
/ 3.0
})
.collect()
}
fn path_data(&self) -> String {
format!(
"M {} {} L {} {} L {} {} Z",
fmt(self.0[0].x),
fmt(self.0[0].y),
fmt(self.0[1].x),
fmt(self.0[1].y),
fmt(self.0[2].x),
fmt(self.0[2].y)
)
}
}
#[allow(clippy::too_many_arguments)]
fn parse_mesh_triangles(
data: &[u8],
shading_type: i64,
coordinate_bits: usize,
component_bits: usize,
flag_bits: usize,
component_count: usize,
decode: &[f64],
vertices_per_row: usize,
) -> Vec<MeshTriangle> {
let mut reader = MeshBitReader::new(data);
if shading_type == 4 {
let mut triangles = Vec::new();
let mut previous = None::<MeshTriangle>;
while let Some((flag, vertex)) = read_mesh_vertex(
&mut reader,
flag_bits,
coordinate_bits,
component_bits,
component_count,
decode,
) {
let triangle = if flag == 0 || previous.is_none() {
let Some((_, second)) = read_mesh_vertex(
&mut reader,
flag_bits,
coordinate_bits,
component_bits,
component_count,
decode,
) else {
break;
};
let Some((_, third)) = read_mesh_vertex(
&mut reader,
flag_bits,
coordinate_bits,
component_bits,
component_count,
decode,
) else {
break;
};
MeshTriangle([vertex, second, third])
} else {
let Some(previous) = previous.as_ref() else {
continue;
};
if flag == 1 {
MeshTriangle([previous.0[1].clone(), previous.0[2].clone(), vertex])
} else {
MeshTriangle([previous.0[0].clone(), previous.0[2].clone(), vertex])
}
};
previous = Some(triangle.clone());
triangles.push(triangle);
}
return triangles;
}
if shading_type == 5 && vertices_per_row >= 2 {
let mut vertices = Vec::new();
while let Some((_, vertex)) = read_mesh_vertex(
&mut reader,
0,
coordinate_bits,
component_bits,
component_count,
decode,
) {
vertices.push(vertex);
}
let rows = vertices.len() / vertices_per_row;
let mut triangles = Vec::new();
for row in 0..rows.saturating_sub(1) {
for column in 0..vertices_per_row - 1 {
let upper_left = vertices[row * vertices_per_row + column].clone();
let upper_right = vertices[row * vertices_per_row + column + 1].clone();
let lower_left = vertices[(row + 1) * vertices_per_row + column].clone();
let lower_right = vertices[(row + 1) * vertices_per_row + column + 1].clone();
triangles.push(MeshTriangle([
upper_left,
upper_right.clone(),
lower_left.clone(),
]));
triangles.push(MeshTriangle([upper_right, lower_right, lower_left]));
}
}
return triangles;
}
Vec::new()
}
fn read_mesh_vertex(
reader: &mut MeshBitReader<'_>,
flag_bits: usize,
coordinate_bits: usize,
component_bits: usize,
component_count: usize,
decode: &[f64],
) -> Option<(u32, MeshVertex)> {
let flag = if flag_bits == 0 {
0
} else {
reader.read(flag_bits)? as u32
};
let x = decode_mesh_value(
reader.read(coordinate_bits)?,
coordinate_bits,
decode[0],
decode[1],
);
let y = decode_mesh_value(
reader.read(coordinate_bits)?,
coordinate_bits,
decode[2],
decode[3],
);
let mut components = Vec::with_capacity(component_count);
for index in 0..component_count {
components.push(decode_mesh_value(
reader.read(component_bits)?,
component_bits,
decode[4 + index * 2],
decode[5 + index * 2],
));
}
Some((flag, MeshVertex { x, y, components }))
}
fn decode_mesh_value(raw: u64, bits: usize, minimum: f64, maximum: f64) -> f64 {
let largest = if bits == 64 {
u64::MAX
} else {
(1u64 << bits) - 1
};
if largest == 0 {
minimum
} else {
minimum + raw as f64 / largest as f64 * (maximum - minimum)
}
}
fn subdivide_mesh_triangle(triangle: &MeshTriangle, divisions: usize) -> Vec<MeshTriangle> {
let mut output = Vec::with_capacity(divisions * divisions);
for first in 0..divisions {
for second in 0..divisions - first {
let a = mesh_barycentric_vertex(triangle, first, second, divisions);
let b = mesh_barycentric_vertex(triangle, first + 1, second, divisions);
let c = mesh_barycentric_vertex(triangle, first, second + 1, divisions);
output.push(MeshTriangle([a, b.clone(), c.clone()]));
if first + second + 1 < divisions {
let d = mesh_barycentric_vertex(triangle, first + 1, second + 1, divisions);
output.push(MeshTriangle([b, d, c]));
}
}
}
output
}
fn mesh_barycentric_vertex(
triangle: &MeshTriangle,
first: usize,
second: usize,
divisions: usize,
) -> MeshVertex {
let first_weight = first as f64 / divisions as f64;
let second_weight = second as f64 / divisions as f64;
let third_weight = 1.0 - first_weight - second_weight;
let weights = [third_weight, first_weight, second_weight];
let component_count = triangle.0[0].components.len();
MeshVertex {
x: triangle
.0
.iter()
.zip(weights)
.map(|(vertex, weight)| vertex.x * weight)
.sum(),
y: triangle
.0
.iter()
.zip(weights)
.map(|(vertex, weight)| vertex.y * weight)
.sum(),
components: (0..component_count)
.map(|index| {
triangle
.0
.iter()
.zip(weights)
.map(|(vertex, weight)| {
vertex.components.get(index).copied().unwrap_or(0.0) * weight
})
.sum()
})
.collect(),
}
}
#[derive(Clone, Debug)]
struct PatchMesh {
shading_type: i64,
points: Vec<(f64, f64)>,
colors: [Vec<f64>; 4],
}
#[allow(clippy::too_many_arguments)]
fn parse_patch_meshes(
data: &[u8],
shading_type: i64,
coordinate_bits: usize,
component_bits: usize,
flag_bits: usize,
component_count: usize,
decode: &[f64],
) -> (Vec<PatchMesh>, bool) {
let mut reader = MeshBitReader::new(data);
let mut patches = Vec::new();
let mut has_reused_patch = false;
let point_count = if shading_type == 6 { 12 } else { 16 };
let mut previous = None::<PatchMesh>;
loop {
let Some(flag) = reader.read(flag_bits.max(1)) else {
break;
};
if flag > 3 || (flag != 0 && previous.is_none()) {
has_reused_patch = true;
break;
}
let mut points = if flag == 0 {
Vec::with_capacity(point_count)
} else {
let previous = previous.as_ref().expect("checked above");
patch_shared_edge(previous, flag as usize).to_vec()
};
let mut complete = true;
for _ in points.len()..point_count {
let Some(raw_x) = reader.read(coordinate_bits) else {
complete = false;
break;
};
let Some(raw_y) = reader.read(coordinate_bits) else {
complete = false;
break;
};
points.push((
decode_mesh_value(raw_x, coordinate_bits, decode[0], decode[1]),
decode_mesh_value(raw_y, coordinate_bits, decode[2], decode[3]),
));
}
if !complete {
break;
}
let mut colors: [Vec<f64>; 4] = if flag == 0 {
std::array::from_fn(|_| Vec::new())
} else {
let previous = previous.as_ref().expect("checked above");
let (first, second) = patch_shared_colors(previous, flag as usize);
[first.clone(), second.clone(), Vec::new(), Vec::new()]
};
let color_start = if flag == 0 { 0 } else { 2 };
for color in &mut colors[color_start..] {
for component in 0..component_count {
let Some(raw) = reader.read(component_bits) else {
complete = false;
break;
};
color.push(decode_mesh_value(
raw,
component_bits,
decode[4 + component * 2],
decode[5 + component * 2],
));
}
}
if !complete {
break;
}
let patch = PatchMesh {
shading_type,
points,
colors,
};
previous = Some(patch.clone());
patches.push(patch);
}
(patches, has_reused_patch)
}
fn patch_shared_edge(patch: &PatchMesh, flag: usize) -> [(f64, f64); 4] {
match flag {
1 => [
patch.points[3],
patch.points[4],
patch.points[5],
patch.points[6],
],
2 => [
patch.points[6],
patch.points[7],
patch.points[8],
patch.points[9],
],
_ => [
patch.points[9],
patch.points[10],
patch.points[11],
patch.points[0],
],
}
}
fn patch_shared_colors(patch: &PatchMesh, flag: usize) -> (&Vec<f64>, &Vec<f64>) {
match flag {
1 => (&patch.colors[1], &patch.colors[2]),
2 => (&patch.colors[2], &patch.colors[3]),
_ => (&patch.colors[3], &patch.colors[0]),
}
}
fn tessellate_patch(patch: &PatchMesh, divisions: usize) -> Vec<MeshTriangle> {
let mut triangles = Vec::with_capacity(divisions * divisions * 2);
for u_index in 0..divisions {
for v_index in 0..divisions {
let u0 = u_index as f64 / divisions as f64;
let u1 = (u_index + 1) as f64 / divisions as f64;
let v0 = v_index as f64 / divisions as f64;
let v1 = (v_index + 1) as f64 / divisions as f64;
let lower_left = patch_vertex(patch, u0, v0);
let lower_right = patch_vertex(patch, u1, v0);
let upper_left = patch_vertex(patch, u0, v1);
let upper_right = patch_vertex(patch, u1, v1);
triangles.push(MeshTriangle([
lower_left,
lower_right.clone(),
upper_left.clone(),
]));
triangles.push(MeshTriangle([lower_right, upper_right, upper_left]));
}
}
triangles
}
fn patch_vertex(patch: &PatchMesh, u: f64, v: f64) -> MeshVertex {
let (x, y) = if patch.shading_type == 7 {
tensor_patch_point(&patch.points, u, v)
} else {
coons_patch_point(&patch.points, u, v)
};
let component_count = patch.colors[0].len();
let weights = [(1.0 - u) * (1.0 - v), (1.0 - u) * v, u * v, u * (1.0 - v)];
MeshVertex {
x,
y,
components: (0..component_count)
.map(|index| {
patch
.colors
.iter()
.zip(weights)
.map(|(color, weight)| color.get(index).copied().unwrap_or(0.0) * weight)
.sum()
})
.collect(),
}
}
fn tensor_patch_point(points: &[(f64, f64)], u: f64, v: f64) -> (f64, f64) {
if points.len() < 16 {
return (0.0, 0.0);
}
let grid = [
[points[0], points[1], points[2], points[3]],
[points[11], points[12], points[13], points[4]],
[points[10], points[15], points[14], points[5]],
[points[9], points[8], points[7], points[6]],
];
let u_basis = cubic_bernstein(u);
let v_basis = cubic_bernstein(v);
let mut x = 0.0;
let mut y = 0.0;
for u_index in 0..4 {
for v_index in 0..4 {
let weight = u_basis[u_index] * v_basis[v_index];
x += grid[u_index][v_index].0 * weight;
y += grid[u_index][v_index].1 * weight;
}
}
(x, y)
}
fn coons_patch_point(points: &[(f64, f64)], u: f64, v: f64) -> (f64, f64) {
if points.len() < 12 {
return (0.0, 0.0);
}
let left = cubic_bezier_point([points[0], points[1], points[2], points[3]], v);
let top = cubic_bezier_point([points[3], points[4], points[5], points[6]], u);
let right = cubic_bezier_point([points[9], points[8], points[7], points[6]], v);
let bottom = cubic_bezier_point([points[0], points[11], points[10], points[9]], u);
let bottom_left = points[0];
let top_left = points[3];
let top_right = points[6];
let bottom_right = points[9];
let bilinear = (
bottom_left.0 * (1.0 - u) * (1.0 - v)
+ top_left.0 * (1.0 - u) * v
+ top_right.0 * u * v
+ bottom_right.0 * u * (1.0 - v),
bottom_left.1 * (1.0 - u) * (1.0 - v)
+ top_left.1 * (1.0 - u) * v
+ top_right.1 * u * v
+ bottom_right.1 * u * (1.0 - v),
);
(
(1.0 - u) * left.0 + u * right.0 + (1.0 - v) * bottom.0 + v * top.0 - bilinear.0,
(1.0 - u) * left.1 + u * right.1 + (1.0 - v) * bottom.1 + v * top.1 - bilinear.1,
)
}
fn cubic_bezier_point(points: [(f64, f64); 4], value: f64) -> (f64, f64) {
let basis = cubic_bernstein(value);
(
points
.iter()
.zip(basis)
.map(|(point, weight)| point.0 * weight)
.sum(),
points
.iter()
.zip(basis)
.map(|(point, weight)| point.1 * weight)
.sum(),
)
}
fn cubic_bernstein(value: f64) -> [f64; 4] {
let inverse = 1.0 - value;
[
inverse * inverse * inverse,
3.0 * value * inverse * inverse,
3.0 * value * value * inverse,
value * value * value,
]
}
struct MeshBitReader<'a> {
data: &'a [u8],
bit_position: usize,
}
impl<'a> MeshBitReader<'a> {
fn new(data: &'a [u8]) -> Self {
Self {
data,
bit_position: 0,
}
}
fn read(&mut self, bits: usize) -> Option<u64> {
if bits == 0 || bits > 64 || self.bit_position + bits > self.data.len() * 8 {
return None;
}
let mut value = 0u64;
for _ in 0..bits {
let byte = self.data[self.bit_position / 8];
let bit = (byte >> (7 - self.bit_position % 8)) & 1;
value = (value << 1) | u64::from(bit);
self.bit_position += 1;
}
Some(value)
}
}
fn image_rendering_hint(
interpolate: bool,
width: usize,
height: usize,
placement: Matrix,
) -> String {
if interpolate {
return "auto".into();
}
let drawn_width = placement[0].hypot(placement[1]);
let drawn_height = placement[2].hypot(placement[3]);
let magnified = drawn_width > width as f64 || drawn_height > height as f64;
if magnified {
"pixelated".into()
} else {
"auto".into()
}
}
fn png_predictor_of(document: &Document, stream: &Stream) -> Option<PngPredictor> {
let raw = [b"DecodeParms".as_slice(), b"DP".as_slice()]
.into_iter()
.find_map(|key| stream.dict.get(key).ok())?;
let resolved = document
.dereference(raw)
.ok()
.map(|(_, value)| value)
.unwrap_or(raw);
let parameters = match resolved {
Object::Dictionary(dictionary) => dictionary.clone(),
Object::Array(entries) => entries
.iter()
.filter_map(|entry| {
document
.dereference(entry)
.ok()
.map(|(_, value)| value)
.unwrap_or(entry)
.as_dict()
.ok()
.cloned()
})
.find(|dictionary| dictionary.get(b"Predictor").is_ok())?,
_ => return None,
};
let predictor = parameters.get(b"Predictor").and_then(Object::as_i64).ok()?;
if !(10..=15).contains(&predictor) {
return None;
}
let colors = parameters
.get(b"Colors")
.and_then(Object::as_i64)
.unwrap_or(1)
.max(1) as usize;
let bits = parameters
.get(b"BitsPerComponent")
.and_then(Object::as_i64)
.unwrap_or(8)
.max(1) as usize;
let columns = parameters
.get(b"Columns")
.and_then(Object::as_i64)
.unwrap_or(1)
.max(1) as usize;
Some(PngPredictor {
bytes_per_pixel: (colors * bits).div_ceil(8).max(1),
row_bytes: (columns * colors * bits).div_ceil(8).max(1),
})
}
#[derive(Clone, Copy, Debug)]
struct PngPredictor {
bytes_per_pixel: usize,
row_bytes: usize,
}
impl PngPredictor {
fn unfilter(&self, data: &[u8]) -> Vec<u8> {
let stride = self.row_bytes;
let bpp = self.bytes_per_pixel.min(stride);
let rows = data.len() / (stride + 1);
let mut output = Vec::with_capacity(rows * stride);
let mut previous = vec![0u8; stride];
let mut current = vec![0u8; stride];
for row in 0..rows {
let start = row * (stride + 1);
let filter = data[start];
current.copy_from_slice(&data[start + 1..start + 1 + stride]);
match filter {
1 => {
for index in bpp..stride {
current[index] = current[index].wrapping_add(current[index - bpp]);
}
}
2 => {
for index in 0..stride {
current[index] = current[index].wrapping_add(previous[index]);
}
}
3 => {
for index in 0..stride {
let left = if index >= bpp {
u16::from(current[index - bpp])
} else {
0
};
let above = u16::from(previous[index]);
current[index] = current[index].wrapping_add(((left + above) / 2) as u8);
}
}
4 => {
for index in 0..stride {
let (left, upper_left) = if index >= bpp {
(current[index - bpp], previous[index - bpp])
} else {
(0, 0)
};
current[index] =
current[index].wrapping_add(paeth(left, previous[index], upper_left));
}
}
_ => {}
}
output.extend_from_slice(¤t);
previous.copy_from_slice(¤t);
}
output
}
}
fn paeth(left: u8, above: u8, upper_left: u8) -> u8 {
let estimate = i16::from(left) + i16::from(above) - i16::from(upper_left);
let distance_left = (estimate - i16::from(left)).abs();
let distance_above = (estimate - i16::from(above)).abs();
let distance_upper_left = (estimate - i16::from(upper_left)).abs();
if distance_left <= distance_above && distance_left <= distance_upper_left {
left
} else if distance_above <= distance_upper_left {
above
} else {
upper_left
}
}
fn decode_predicted_content(
document: &Document,
stream: &Stream,
content_limit: usize,
) -> Result<Vec<u8>> {
let Some(predictor) = png_predictor_of(document, stream) else {
return Ok(stream.decompressed_content_with_limit(content_limit)?);
};
let mut raw = stream.clone();
raw.dict.remove(b"DecodeParms");
raw.dict.remove(b"DP");
let filtered = raw.decompressed_content_with_limit(content_limit)?;
Ok(predictor.unfilter(&filtered))
}
fn pdf_color_component_count(document: &Document, color_space: Option<&Object>) -> usize {
let color_space =
color_space.and_then(|value| document.dereference(value).ok().map(|(_, value)| value));
match color_space {
Some(Object::Name(name)) if name == b"DeviceGray" || name == b"G" => 1,
Some(Object::Name(name)) if name == b"DeviceCMYK" => 4,
Some(Object::Array(array)) => match array.first().and_then(|value| value.as_name().ok()) {
Some(b"Indexed") => 1,
Some(b"Separation") => 1,
Some(b"DeviceN") => array
.get(1)
.and_then(|value| value.as_array().ok())
.map_or(1, Vec::len),
Some(b"ICCBased") => array
.get(1)
.and_then(|value| document.dereference(value).ok().map(|(_, value)| value))
.and_then(|value| value.as_stream().ok())
.and_then(|stream| stream.dict.get(b"N").and_then(Object::as_i64).ok())
.unwrap_or(3)
.max(1) as usize,
Some(b"CalGray") => 1,
Some(b"CalRGB" | b"Lab") => 3,
Some(b"Pattern") => array
.get(1)
.map(|base| pdf_color_component_count(document, Some(base)))
.unwrap_or(0),
_ => 3,
},
_ => 3,
}
}
fn pdf_color_space_is_pattern(document: &Document, color_space: &Object) -> bool {
let color_space = document
.dereference(color_space)
.ok()
.map(|(_, value)| value)
.unwrap_or(color_space);
matches!(color_space, Object::Name(name) if name == b"Pattern")
|| matches!(
color_space,
Object::Array(array)
if array.first().and_then(|value| value.as_name().ok()) == Some(b"Pattern")
)
}
fn components_to_color(
document: &Document,
color_space: Option<&Object>,
components: &[f64],
) -> String {
let [red, green, blue] = components_to_rgb(document, color_space, components, 0)
.unwrap_or_else(|| default_rgb_components(components));
let [red, green, blue] = rgb_to_bytes([red, green, blue]);
format!("#{red:02X}{green:02X}{blue:02X}")
}
fn components_to_rgb(
document: &Document,
color_space: Option<&Object>,
components: &[f64],
depth: usize,
) -> Option<[f64; 3]> {
if depth > 16 {
return None;
}
let color_space =
color_space.and_then(|value| document.dereference(value).ok().map(|(_, value)| value));
match color_space {
Some(Object::Name(name)) if name == b"DeviceGray" || name == b"G" => {
let gray = components.first().copied().unwrap_or(0.0);
Some([gray, gray, gray])
}
Some(Object::Name(name)) if name == b"DeviceCMYK" || name == b"CMYK" => {
Some(cmyk_components_to_rgb(components))
}
Some(Object::Name(name)) if name == b"DeviceRGB" || name == b"RGB" => {
Some(default_rgb_components(components))
}
Some(Object::Array(array)) => {
let kind = array.first().and_then(|value| value.as_name().ok())?;
match kind {
b"Separation" => {
let alternate = array.get(2)?;
let function = array.get(3)?;
let converted = evaluate_pdf_function_inputs(
document,
function,
&components[..components.len().min(1)],
0,
)?;
components_to_rgb(document, Some(alternate), &converted, depth + 1)
}
b"DeviceN" => {
let alternate = array.get(2)?;
let function = array.get(3)?;
let converted =
evaluate_pdf_function_inputs(document, function, components, 0)?;
components_to_rgb(document, Some(alternate), &converted, depth + 1)
}
b"Indexed" => {
let base = array.get(1)?;
let maximum = integer(array.get(2)).unwrap_or(255).clamp(0, 255) as usize;
let lookup = object_bytes(array.get(3)?)?;
let count = pdf_color_component_count(document, Some(base));
let index = components
.first()
.copied()
.unwrap_or(0.0)
.round()
.clamp(0.0, maximum as f64) as usize;
let offset = index.checked_mul(count)?;
let sample = lookup.get(offset..offset.checked_add(count)?)?;
let sample = sample
.iter()
.map(|value| f64::from(*value) / 255.0)
.collect::<Vec<_>>();
components_to_rgb(document, Some(base), &sample, depth + 1)
}
b"ICCBased" => {
let profile = array
.get(1)
.and_then(|value| document.dereference(value).ok().map(|(_, value)| value))
.and_then(|value| value.as_stream().ok())?;
if let Ok(alternate) = profile.dict.get(b"Alternate") {
return components_to_rgb(document, Some(alternate), components, depth + 1);
}
match profile.dict.get(b"N").and_then(Object::as_i64).unwrap_or(3) {
1 => {
let gray = components.first().copied().unwrap_or(0.0);
Some([gray, gray, gray])
}
4 => Some(cmyk_components_to_rgb(components)),
_ => Some(default_rgb_components(components)),
}
}
b"CalGray" => {
let dictionary = array.get(1).and_then(|value| value.as_dict().ok());
let gamma = dictionary
.and_then(|value| value.get(b"Gamma").ok())
.and_then(|value| number(Some(value)))
.unwrap_or(1.0);
let gray = srgb_encode(
components
.first()
.copied()
.unwrap_or(0.0)
.clamp(0.0, 1.0)
.powf(gamma),
);
Some([gray, gray, gray])
}
b"CalRGB" => cal_rgb_to_srgb(array.get(1), components),
b"Lab" => lab_to_srgb(array.get(1), components),
b"Pattern" => array.get(1).and_then(|base| {
components_to_rgb(document, Some(base), components, depth + 1)
}),
_ => Some(default_rgb_components(components)),
}
}
_ => Some(default_rgb_components(components)),
}
}
fn default_rgb_components(components: &[f64]) -> [f64; 3] {
let red = components.first().copied().unwrap_or(0.0);
[
red,
components.get(1).copied().unwrap_or(red),
components.get(2).copied().unwrap_or(red),
]
}
fn cmyk_components_to_rgb(components: &[f64]) -> [f64; 3] {
let cyan = components.first().copied().unwrap_or(0.0);
let magenta = components.get(1).copied().unwrap_or(0.0);
let yellow = components.get(2).copied().unwrap_or(0.0);
let black = components.get(3).copied().unwrap_or(0.0);
[
1.0 - (cyan + black).min(1.0),
1.0 - (magenta + black).min(1.0),
1.0 - (yellow + black).min(1.0),
]
}
fn rgb_to_bytes(rgb: [f64; 3]) -> [u8; 3] {
rgb.map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8)
}
fn cal_rgb_to_srgb(parameters: Option<&Object>, components: &[f64]) -> Option<[f64; 3]> {
let dictionary = parameters.and_then(|value| value.as_dict().ok())?;
let gamma = dictionary
.get(b"Gamma")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 3))
.filter(|values| values.len() == 3)
.unwrap_or_else(|| vec![1.0, 1.0, 1.0]);
let matrix = dictionary
.get(b"Matrix")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 9))
.filter(|values| values.len() == 9)
.unwrap_or_else(|| vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let adjusted = [
components
.first()
.copied()
.unwrap_or(0.0)
.clamp(0.0, 1.0)
.powf(gamma[0]),
components
.get(1)
.copied()
.unwrap_or(0.0)
.clamp(0.0, 1.0)
.powf(gamma[1]),
components
.get(2)
.copied()
.unwrap_or(0.0)
.clamp(0.0, 1.0)
.powf(gamma[2]),
];
xyz_to_srgb([
matrix[0] * adjusted[0] + matrix[3] * adjusted[1] + matrix[6] * adjusted[2],
matrix[1] * adjusted[0] + matrix[4] * adjusted[1] + matrix[7] * adjusted[2],
matrix[2] * adjusted[0] + matrix[5] * adjusted[1] + matrix[8] * adjusted[2],
])
}
fn lab_to_srgb(parameters: Option<&Object>, components: &[f64]) -> Option<[f64; 3]> {
let dictionary = parameters.and_then(|value| value.as_dict().ok())?;
let white = dictionary
.get(b"WhitePoint")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 3))
.filter(|values| values.len() == 3)
.unwrap_or_else(|| vec![0.95047, 1.0, 1.08883]);
let lightness = components.first().copied().unwrap_or(0.0).clamp(0.0, 100.0);
let a = components.get(1).copied().unwrap_or(0.0);
let b = components.get(2).copied().unwrap_or(0.0);
let fy = (lightness + 16.0) / 116.0;
let fx = fy + a / 500.0;
let fz = fy - b / 200.0;
let inverse = |value: f64| {
let cube = value * value * value;
if cube > 216.0 / 24_389.0 {
cube
} else {
(116.0 * value - 16.0) / 903.3
}
};
xyz_to_srgb([
white[0] * inverse(fx),
white[1] * inverse(fy),
white[2] * inverse(fz),
])
}
fn xyz_to_srgb(xyz: [f64; 3]) -> Option<[f64; 3]> {
if xyz.iter().any(|value| !value.is_finite()) {
return None;
}
let linear = [
3.2406 * xyz[0] - 1.5372 * xyz[1] - 0.4986 * xyz[2],
-0.9689 * xyz[0] + 1.8758 * xyz[1] + 0.0415 * xyz[2],
0.0557 * xyz[0] - 0.2040 * xyz[1] + 1.0570 * xyz[2],
];
Some(linear.map(srgb_encode))
}
fn srgb_encode(value: f64) -> f64 {
if value <= 0.003_130_8 {
12.92 * value
} else {
1.055 * value.max(0.0).powf(1.0 / 2.4) - 0.055
}
}
fn transformed_axial_axis(
matrix: Matrix,
x0: f64,
y0: f64,
x1: f64,
y1: f64,
) -> Option<(f64, f64, f64, f64)> {
let vector_x = x1 - x0;
let vector_y = y1 - y0;
let source_length_squared = vector_x * vector_x + vector_y * vector_y;
if source_length_squared <= 1e-18 {
return None;
}
let [a, b, c, d, _, _] = matrix;
let determinant = a * d - b * c;
if determinant.abs() <= 1e-15 {
return None;
}
let gradient_x = (d * vector_x - b * vector_y) / (determinant * source_length_squared);
let gradient_y = (-c * vector_x + a * vector_y) / (determinant * source_length_squared);
let gradient_length_squared = gradient_x * gradient_x + gradient_y * gradient_y;
if gradient_length_squared <= 1e-24 {
return None;
}
let transformed_vector_x = gradient_x / gradient_length_squared;
let transformed_vector_y = gradient_y / gradient_length_squared;
let start = transform_point(matrix, x0, y0);
Some((
start.0,
start.1,
start.0 + transformed_vector_x,
start.1 + transformed_vector_y,
))
}
fn matrix_maximum_scale(matrix: Matrix) -> f64 {
let [a, b, c, d, _, _] = matrix;
let first_squared = a * a + b * b;
let second_squared = c * c + d * d;
let cross = a * c + b * d;
let discriminant = (first_squared - second_squared).hypot(2.0 * cross);
((first_squared + second_squared + discriminant) / 2.0)
.max(0.0)
.sqrt()
}
fn rectangle_path(x: f64, y: f64, width: f64, height: f64) -> String {
format!(
"M {} {} H {} V {} H {} Z",
fmt(x),
fmt(y),
fmt(x + width),
fmt(y + height),
fmt(x)
)
}
fn quadrilateral_path(points: [(f64, f64); 4]) -> String {
format!(
"M {} {} L {} {} L {} {} L {} {} Z",
fmt(points[0].0),
fmt(points[0].1),
fmt(points[1].0),
fmt(points[1].1),
fmt(points[2].0),
fmt(points[2].1),
fmt(points[3].0),
fmt(points[3].1),
)
}
fn hidden_optional_content_groups(document: &Document) -> HashSet<ObjectId> {
let Some(configuration) = document
.catalog()
.ok()
.and_then(|catalog| catalog.get_deref(b"OCProperties", document).ok())
.and_then(|value| value.as_dict().ok())
.and_then(|properties| properties.get_deref(b"D", document).ok())
.and_then(|value| value.as_dict().ok())
else {
return HashSet::new();
};
let object_ids = |key: &[u8]| -> HashSet<ObjectId> {
configuration
.get_deref(key, document)
.and_then(Object::as_array)
.ok()
.into_iter()
.flatten()
.filter_map(|value| value.as_reference().ok())
.collect()
};
let all_off = configuration
.get(b"BaseState")
.and_then(Object::as_name)
.ok()
== Some(b"OFF");
if all_off {
let all_groups = document
.catalog()
.ok()
.and_then(|catalog| catalog.get_deref(b"OCProperties", document).ok())
.and_then(|value| value.as_dict().ok())
.and_then(|properties| {
properties
.get_deref(b"OCGs", document)
.and_then(Object::as_array)
.ok()
})
.into_iter()
.flatten()
.filter_map(|value| value.as_reference().ok())
.collect::<HashSet<_>>();
let visible = object_ids(b"ON");
all_groups.difference(&visible).copied().collect()
} else {
object_ids(b"OFF")
}
}
fn inherited_page_array(
document: &Document,
mut page_id: ObjectId,
key: &[u8],
) -> Option<[f64; 4]> {
for _ in 0..64 {
let page = document.get_dictionary(page_id).ok()?;
if let Ok(array) = page.get_deref(key, document).and_then(Object::as_array) {
let values = array
.iter()
.filter_map(|object| number(Some(object)))
.collect::<Vec<_>>();
if values.len() >= 4 {
return Some([values[0], values[1], values[2], values[3]]);
}
}
page_id = page.get(b"Parent").and_then(Object::as_reference).ok()?;
}
None
}
fn inherited_page_number(document: &Document, mut page_id: ObjectId, key: &[u8]) -> Option<f64> {
for _ in 0..64 {
let page = document.get_dictionary(page_id).ok()?;
if let Ok(value) = page.get_deref(key, document)
&& let Some(number) = number(Some(value))
{
return Some(number);
}
page_id = page.get(b"Parent").and_then(Object::as_reference).ok()?;
}
None
}
fn page_user_unit(document: &Document, page_id: ObjectId) -> Result<f64> {
let page = document.get_dictionary(page_id)?;
let Ok(value) = page.get(b"UserUnit") else {
return Ok(1.0);
};
let (_, value) = document.dereference(value)?;
let Some(value) = number(Some(value)) else {
return Err(Error::InvalidInput(
"PDF page UserUnit is not numeric".into(),
));
};
if !value.is_finite() || value <= 0.0 || value > 75_000.0 {
return Err(Error::InvalidInput(format!(
"PDF page UserUnit {value} is outside the supported positive range"
)));
}
Ok(value)
}
fn page_resource_dicts(
document: &Document,
mut page_id: ObjectId,
) -> impl Iterator<Item = &Dictionary> {
let mut result = Vec::new();
for _ in 0..64 {
let Ok(page) = document.get_dictionary(page_id) else {
break;
};
if let Ok(resources) = page
.get_deref(b"Resources", document)
.and_then(Object::as_dict)
{
result.push(resources);
}
let Ok(parent) = page.get(b"Parent").and_then(Object::as_reference) else {
break;
};
page_id = parent;
}
result.into_iter()
}
fn page_matrix(bounds: [f64; 4], rotation: i32) -> Matrix {
let [left, bottom, right, top] = bounds;
match rotation {
90 => [0.0, 1.0, 1.0, 0.0, -bottom, -left],
180 => [-1.0, 0.0, 0.0, 1.0, right, -bottom],
270 => [0.0, -1.0, -1.0, 0.0, top, right],
_ => [1.0, 0.0, 0.0, -1.0, -left, top],
}
}
fn pattern_tile_margin(span: f64) -> f64 {
span.abs().max(1.0) * 0.002
}
fn matrix_operands(operands: &[Object]) -> Option<Matrix> {
let values = numbers(operands, 6);
(values.len() == 6).then(|| {
[
values[0], values[1], values[2], values[3], values[4], values[5],
]
})
}
fn numbers(operands: &[Object], maximum: usize) -> Vec<f64> {
operands
.iter()
.take(maximum)
.filter_map(|object| number(Some(object)))
.collect()
}
fn number(object: Option<&Object>) -> Option<f64> {
match object? {
Object::Integer(value) => Some(*value as f64),
Object::Real(value) => Some(f64::from(*value)),
_ => None,
}
}
fn integer(object: Option<&Object>) -> Option<i64> {
match object? {
Object::Integer(value) => Some(*value),
Object::Real(value) => Some(value.round() as i64),
_ => None,
}
}
fn name(object: Option<&Object>) -> Option<&[u8]> {
object?.as_name().ok()
}
fn string_bytes(object: Option<&Object>) -> Option<&[u8]> {
object?.as_str().ok()
}
fn pdf_string_array(document: &Document, array: &[Object]) -> Option<Vec<Vec<u8>>> {
if array.len() > MAX_PDF_CHOICE_OPTIONS {
return None;
}
let mut total_bytes = 0usize;
let mut values = Vec::with_capacity(array.len());
for item in array {
let value = document.dereference(item).ok()?.1;
let text = string_bytes(Some(value))?.to_vec();
total_bytes = total_bytes.saturating_add(text.len());
if total_bytes > MAX_PDF_CHOICE_TEXT_BYTES {
return None;
}
values.push(text);
}
Some(values)
}
fn pdf_choice_options(document: &Document, object: &Object) -> Option<Vec<PdfChoiceOption>> {
let object = document.dereference(object).ok()?.1;
let array = object.as_array().ok()?;
if array.len() > MAX_PDF_CHOICE_OPTIONS {
return None;
}
let mut total_bytes = 0usize;
let mut options = Vec::with_capacity(array.len());
for item in array {
let item = document.dereference(item).ok()?.1;
let (export_value, display_value) = if let Ok(bytes) = item.as_str() {
(bytes.to_vec(), bytes.to_vec())
} else if let Ok(pair) = item.as_array() {
if pair.len() != 2 {
continue;
}
let Some(export) = document
.dereference(&pair[0])
.ok()
.and_then(|(_, value)| string_bytes(Some(value)))
.map(ToOwned::to_owned)
else {
continue;
};
let Some(display) = document
.dereference(&pair[1])
.ok()
.and_then(|(_, value)| string_bytes(Some(value)))
.map(ToOwned::to_owned)
else {
continue;
};
(export, display)
} else {
continue;
};
total_bytes = total_bytes
.saturating_add(export_value.len())
.saturating_add(display_value.len());
if total_bytes > MAX_PDF_CHOICE_TEXT_BYTES {
return None;
}
options.push(PdfChoiceOption {
export_value,
display_value,
});
}
Some(options)
}
fn pdf_choice_display_value<'a>(
options: &'a [PdfChoiceOption],
export_value: &[u8],
) -> Option<&'a [u8]> {
options
.iter()
.find(|option| option.export_value == export_value)
.map(|option| option.display_value.as_slice())
}
fn decode_pdf_annotation_text(bytes: &[u8]) -> String {
let decoded = if let Some(bytes) = bytes.strip_prefix(&[0xfe, 0xff]) {
if bytes.len().is_multiple_of(2) {
char::decode_utf16(
bytes
.chunks_exact(2)
.map(|pair| u16::from_be_bytes([pair[0], pair[1]])),
)
.map(|character| character.unwrap_or(char::REPLACEMENT_CHARACTER))
.collect::<String>()
} else {
String::from_utf8_lossy(bytes).into_owned()
}
} else if let Some(bytes) = bytes.strip_prefix(&[0xff, 0xfe]) {
if bytes.len().is_multiple_of(2) {
char::decode_utf16(
bytes
.chunks_exact(2)
.map(|pair| u16::from_le_bytes([pair[0], pair[1]])),
)
.map(|character| character.unwrap_or(char::REPLACEMENT_CHARACTER))
.collect::<String>()
} else {
String::from_utf8_lossy(bytes).into_owned()
}
} else {
encoding_rs::WINDOWS_1252
.decode_without_bom_handling(bytes)
.0
.into_owned()
};
decoded
.chars()
.map(|character| match character {
'\r' => '\n',
'\0' => '\u{fffd}',
other => other,
})
.collect()
}
fn wrap_free_text_annotation(
text: &str,
max_chars_per_line: usize,
max_lines: usize,
) -> (Vec<String>, bool) {
let mut lines = Vec::new();
let mut line = String::new();
let mut line_chars = 0usize;
let mut truncated = false;
for character in text.chars() {
if character == '\r' {
continue;
}
if character == '\n' {
if lines.len() >= max_lines {
truncated = true;
break;
}
lines.push(std::mem::take(&mut line));
line_chars = 0;
continue;
}
let character = if character == '\t' { ' ' } else { character };
if character.is_control() {
continue;
}
if line_chars >= max_chars_per_line {
if lines.len() >= max_lines {
truncated = true;
break;
}
lines.push(std::mem::take(&mut line));
line_chars = 0;
}
line.push(character);
line_chars += 1;
}
if !truncated && (!line.is_empty() || lines.is_empty()) {
if lines.len() < max_lines {
lines.push(line);
} else {
truncated = true;
}
}
(lines, truncated)
}
fn free_text_appearance_style(
appearance: &[u8],
quadding: i64,
default_size: f64,
) -> (String, f64, Paint, TextAnchor) {
let text = String::from_utf8_lossy(appearance);
let tokens = text.split_whitespace().collect::<Vec<_>>();
let mut font_name = "Helvetica";
let mut font_size = default_size;
let mut fill = Paint::solid("#000000");
for (index, token) in tokens.iter().enumerate() {
match *token {
"Tf" if index >= 2 => {
font_name = tokens[index - 2].trim_start_matches('/');
if let Ok(size) = tokens[index - 1].parse::<f64>()
&& size.is_finite()
&& size >= 0.0
{
font_size = if size == 0.0 { default_size } else { size };
}
}
"g" if index >= 1 => {
if let Ok(gray) = tokens[index - 1].parse::<f64>() {
fill = gray_paint(gray, 1.0);
}
}
"rg" if index >= 3 => {
if let Some(values) = parse_da_color_values(&tokens[index - 3..index]) {
fill = rgb_paint(&values, 1.0);
}
}
"k" if index >= 4 => {
if let Some(values) = parse_da_color_values(&tokens[index - 4..index]) {
let channel =
|index: usize| (1.0 - (values[index] + values[3]).clamp(0.0, 1.0)) * 255.0;
fill = Paint::solid(format!(
"#{:02X}{:02X}{:02X}",
channel(0).round() as u8,
channel(1).round() as u8,
channel(2).round() as u8
));
}
}
_ => {}
}
}
font_size = font_size.clamp(4.0, 72.0);
let font_family = match font_name {
name if name.starts_with("Helv") || name.starts_with("Arial") => {
"Arial, 'Hiragino Sans', Helvetica, sans-serif"
}
name if name.starts_with("Ti") || name.starts_with("Times") => {
"'Times New Roman', Times, serif"
}
name if name.starts_with("Cour") => "'Courier New', Courier, monospace",
_ => "Arial, 'Hiragino Sans', Helvetica, sans-serif",
}
.to_owned();
let anchor = match quadding {
1 => TextAnchor::Middle,
2 => TextAnchor::End,
_ => TextAnchor::Start,
};
(font_family, font_size, fill, anchor)
}
fn annotation_rect(annotation: &Dictionary, document: &Document) -> Option<Vec<f64>> {
annotation
.get_deref(b"Rect", document)
.ok()
.and_then(|object| object.as_array().ok())
.map(|array| numbers(array, 4))
.filter(|rect| rect.len() == 4 && rect.iter().all(|value| value.is_finite()))
}
fn acroform_default_appearance(document: &Document) -> Option<&[u8]> {
let root = document
.trailer
.get_deref(b"Root", document)
.ok()?
.as_dict()
.ok()?;
let acroform = root.get_deref(b"AcroForm", document).ok()?.as_dict().ok()?;
acroform
.get_deref(b"DA", document)
.ok()
.and_then(|object| string_bytes(Some(object)))
}
fn bounded_form_appearance(
field_appearance: Option<Vec<u8>>,
document: &Document,
) -> (Vec<u8>, bool) {
if let Some(appearance) = field_appearance {
return (appearance, false);
}
match acroform_default_appearance(document) {
Some(appearance) if appearance.len() <= MAX_PDF_DEFAULT_APPEARANCE_BYTES => {
(appearance.to_vec(), false)
}
Some(_) => (Vec::new(), true),
None => (Vec::new(), false),
}
}
fn parse_da_color_values(tokens: &[&str]) -> Option<Vec<f64>> {
tokens
.iter()
.map(|token| token.parse::<f64>().ok().filter(|value| value.is_finite()))
.collect()
}
fn gray_paint(gray: f64, opacity: f64) -> Paint {
let channel = (gray.clamp(0.0, 1.0) * 255.0).round() as u8;
Paint::Solid {
color: format!("#{channel:02X}{channel:02X}{channel:02X}"),
opacity,
}
}
fn rgb_paint(values: &[f64], opacity: f64) -> Paint {
let value = |index: usize| {
(values.get(index).copied().unwrap_or(0.0).clamp(0.0, 1.0) * 255.0).round() as u8
};
Paint::Solid {
color: format!("#{:02X}{:02X}{:02X}", value(0), value(1), value(2)),
opacity,
}
}
fn cmyk_paint(values: &[f64], opacity: f64) -> Paint {
let component = |index: usize| values.get(index).copied().unwrap_or(0.0).clamp(0.0, 1.0);
let k = component(3);
let red = ((1.0 - (component(0) + k).min(1.0)) * 255.0).round() as u8;
let green = ((1.0 - (component(1) + k).min(1.0)) * 255.0).round() as u8;
let blue = ((1.0 - (component(2) + k).min(1.0)) * 255.0).round() as u8;
Paint::Solid {
color: format!("#{red:02X}{green:02X}{blue:02X}"),
opacity,
}
}
fn set_paint_opacity(paint: &mut Paint, opacity: f64) {
match paint {
Paint::Solid {
opacity: current, ..
}
| Paint::PatternRef {
opacity: current, ..
} => *current = opacity,
Paint::LinearGradient(gradient) => {
for stop in &mut gradient.stops {
stop.opacity = opacity;
}
}
Paint::RadialGradient(gradient) => {
for stop in &mut gradient.stops {
stop.opacity = opacity;
}
}
Paint::None => {}
}
}
fn pdf_blend_mode(name: &[u8]) -> String {
match name {
b"Normal" | b"Compatible" => "normal",
b"Multiply" => "multiply",
b"Screen" => "screen",
b"Overlay" => "overlay",
b"Darken" => "darken",
b"Lighten" => "lighten",
b"ColorDodge" => "color-dodge",
b"ColorBurn" => "color-burn",
b"HardLight" => "hard-light",
b"SoftLight" => "soft-light",
b"Difference" => "difference",
b"Exclusion" => "exclusion",
b"Hue" => "hue",
b"Saturation" => "saturation",
b"Color" => "color",
b"Luminosity" => "luminosity",
_ => "normal",
}
.into()
}
fn children_are_opaque_normal(nodes: &[Node]) -> bool {
!nodes.is_empty() && nodes.iter().all(node_is_opaque_normal)
}
fn node_is_opaque_normal(node: &Node) -> bool {
let meta = match node {
Node::Path { meta, .. }
| Node::Text { meta, .. }
| Node::Image { meta, .. }
| Node::Group { meta, .. } => meta,
};
if !meta.mask_id.is_empty() || (!meta.blend_mode.is_empty() && meta.blend_mode != "normal") {
return false;
}
match node {
Node::Path { fill, stroke, .. } => paint_is_opaque(fill) && paint_is_opaque(&stroke.paint),
Node::Text { runs, opacity, .. } => {
*opacity >= 1.0 - 1e-12 && runs.iter().all(|run| paint_is_opaque(&run.fill))
}
Node::Image { .. } => false,
Node::Group { nodes, opacity, .. } => {
*opacity >= 1.0 - 1e-12 && children_are_opaque_normal(nodes)
}
}
}
fn paint_is_opaque(paint: &Paint) -> bool {
match paint {
Paint::None => true,
Paint::Solid { opacity, .. } => *opacity >= 1.0 - 1e-12,
Paint::LinearGradient(gradient) => gradient
.stops
.iter()
.all(|stop| stop.opacity >= 1.0 - 1e-12),
Paint::RadialGradient(gradient) => gradient
.stops
.iter()
.all(|stop| stop.opacity >= 1.0 - 1e-12),
Paint::PatternRef { opacity, .. } => *opacity >= 1.0 - 1e-12,
}
}
fn apply_path_knockout_masks(
page: &mut Page,
children: &mut [Node],
mask_counter: &mut usize,
) -> bool {
if children.len() < 2 || !children.iter().all(knockout_node_supported) {
return false;
}
for index in 0..children.len() - 1 {
let mut mask_nodes = vec![Node::Path {
id: format!("pdf-knockout-backdrop-{}-{index}", page.number),
d: rectangle_path(0.0, 0.0, page.width, page.height),
fill_rule: "nonzero".into(),
fill: Paint::solid("#FFFFFF"),
stroke: Stroke::default(),
transform: IDENTITY,
clip_id: None,
meta: SourceMeta {
kind: "knockout-mask-backdrop".into(),
..SourceMeta::default()
},
}];
mask_nodes.extend(
children[index + 1..]
.iter()
.filter_map(blackened_knockout_node),
);
*mask_counter += 1;
let mask_id = format!("pdf-knockout-mask-{}-{}", page.number, mask_counter);
page.masks.push(crate::ir::MaskDefinition {
id: mask_id.clone(),
mask_type: "luminance".into(),
nodes: mask_nodes,
transfer_values: Vec::new(),
});
node_meta_mut(&mut children[index]).mask_id = mask_id;
}
true
}
fn knockout_node_supported(node: &Node) -> bool {
let meta = match node {
Node::Path { meta, .. } | Node::Group { meta, .. } => meta,
_ => return false,
};
if !meta.mask_id.is_empty() || (!meta.blend_mode.is_empty() && meta.blend_mode != "normal") {
return false;
}
match node {
Node::Path { .. } => true,
Node::Group { nodes, .. } => !nodes.is_empty() && nodes.iter().all(knockout_node_supported),
_ => false,
}
}
fn blackened_knockout_node(node: &Node) -> Option<Node> {
match node {
Node::Path {
id,
d,
fill_rule,
fill,
stroke,
transform,
clip_id,
meta,
} => Some(Node::Path {
id: format!("{id}-knockout-shape"),
d: d.clone(),
fill_rule: fill_rule.clone(),
fill: black_shape_paint(fill, meta.alpha_is_shape),
stroke: Stroke {
paint: black_shape_paint(&stroke.paint, meta.alpha_is_shape),
width: stroke.width,
line_cap: stroke.line_cap,
line_join: stroke.line_join,
miter_limit: stroke.miter_limit,
dash_array: stroke.dash_array.clone(),
dash_offset: stroke.dash_offset,
},
transform: *transform,
clip_id: clip_id.clone(),
meta: SourceMeta {
kind: "knockout-shape".into(),
..SourceMeta::default()
},
}),
Node::Group {
id,
nodes,
transform,
opacity,
clip_id,
meta,
} => Some(Node::Group {
id: format!("{id}-knockout-shape"),
nodes: nodes.iter().filter_map(blackened_knockout_node).collect(),
transform: *transform,
opacity: if meta.alpha_is_shape { *opacity } else { 1.0 },
clip_id: clip_id.clone(),
meta: SourceMeta {
kind: "knockout-shape-group".into(),
..SourceMeta::default()
},
}),
_ => None,
}
}
fn black_shape_paint(paint: &Paint, preserve_alpha: bool) -> Paint {
match paint {
Paint::None => Paint::None,
Paint::Solid { opacity, .. } => Paint::Solid {
color: "#000000".into(),
opacity: if preserve_alpha { *opacity } else { 1.0 },
},
Paint::LinearGradient(gradient) => {
let mut gradient = (**gradient).clone();
for stop in &mut gradient.stops {
stop.color = "#000000".into();
if !preserve_alpha {
stop.opacity = 1.0;
}
}
Paint::LinearGradient(Box::new(gradient))
}
Paint::RadialGradient(gradient) => {
let mut gradient = (**gradient).clone();
for stop in &mut gradient.stops {
stop.color = "#000000".into();
if !preserve_alpha {
stop.opacity = 1.0;
}
}
Paint::RadialGradient(Box::new(gradient))
}
Paint::PatternRef { opacity, .. } => Paint::Solid {
color: "#000000".into(),
opacity: if preserve_alpha { *opacity } else { 1.0 },
},
}
}
fn node_meta_mut(node: &mut Node) -> &mut SourceMeta {
match node {
Node::Path { meta, .. }
| Node::Text { meta, .. }
| Node::Image { meta, .. }
| Node::Group { meta, .. } => meta,
}
}
fn normalize_inline_image(mut stream: Stream) -> Stream {
let keys: [(&[u8], &[u8]); 9] = [
(b"W", b"Width"),
(b"H", b"Height"),
(b"BPC", b"BitsPerComponent"),
(b"CS", b"ColorSpace"),
(b"D", b"Decode"),
(b"DP", b"DecodeParms"),
(b"F", b"Filter"),
(b"I", b"Interpolate"),
(b"IM", b"ImageMask"),
];
for (short, long) in keys {
if !stream.dict.has(long)
&& let Ok(value) = stream.dict.get(short).cloned()
{
stream.dict.set(long, value);
}
}
if let Ok(Object::Name(name)) = stream.dict.get_mut(b"ColorSpace") {
*name = match name.as_slice() {
b"G" => b"DeviceGray".to_vec(),
b"RGB" => b"DeviceRGB".to_vec(),
b"CMYK" => b"DeviceCMYK".to_vec(),
_ => name.clone(),
};
}
stream.dict.set("Subtype", Object::Name(b"Image".to_vec()));
stream
}
fn object_bytes(object: &Object) -> Option<Vec<u8>> {
match object {
Object::String(bytes, _) => Some(bytes.clone()),
Object::Stream(stream) => stream
.decompressed_content()
.ok()
.or_else(|| Some(stream.content.clone())),
_ => None,
}
}
fn cmyk_samples_to_rgb(samples: &[u8]) -> Vec<u8> {
let mut rgb = Vec::with_capacity(samples.len() / 4 * 3);
for sample in samples.chunks_exact(4) {
let cyan = f64::from(sample[0]) / 255.0;
let magenta = f64::from(sample[1]) / 255.0;
let yellow = f64::from(sample[2]) / 255.0;
let black = f64::from(sample[3]) / 255.0;
rgb.extend_from_slice(&[
((1.0 - (cyan + black).min(1.0)) * 255.0).round() as u8,
((1.0 - (magenta + black).min(1.0)) * 255.0).round() as u8,
((1.0 - (yellow + black).min(1.0)) * 255.0).round() as u8,
]);
}
rgb
}
fn normalize_image_samples(
stream: &Stream,
samples: &[u8],
width: usize,
height: usize,
bits_per_component: usize,
components: usize,
color_space: Option<&Object>,
) -> Option<Vec<u8>> {
if components == 0 {
return None;
}
let samples_per_row = width.checked_mul(components)?;
let row_bits = samples_per_row.checked_mul(bits_per_component)?;
let row_bytes = row_bits.div_ceil(8);
if samples.len() < row_bytes.checked_mul(height)? {
return None;
}
let decode = stream
.dict
.get(b"Decode")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, usize::MAX));
let indexed_maximum = match color_space {
Some(Object::Array(values))
if values.first().and_then(|value| value.as_name().ok()) == Some(b"Indexed") =>
{
integer(values.get(2)).map(|value| value.max(0) as f64)
}
_ => None,
};
let is_lab = matches!(
color_space,
Some(Object::Array(values))
if values.first().and_then(|value| value.as_name().ok()) == Some(b"Lab")
);
let largest = (1u64 << bits_per_component) - 1;
let mut output = Vec::with_capacity(width.checked_mul(height)?.checked_mul(components)?);
for row in 0..height {
let row_bit_start = row.checked_mul(row_bytes)?.checked_mul(8)?;
for sample_index in 0..samples_per_row {
let raw = read_bits_at(
samples,
row_bit_start.checked_add(sample_index.checked_mul(bits_per_component)?)?,
bits_per_component,
)?;
let component = sample_index % components;
let default_end = indexed_maximum.unwrap_or(1.0);
let minimum = decode
.as_ref()
.and_then(|values| values.get(component * 2))
.copied()
.unwrap_or(0.0);
let maximum = decode
.as_ref()
.and_then(|values| values.get(component * 2 + 1))
.copied()
.unwrap_or(default_end);
let decoded = minimum + raw as f64 / largest as f64 * (maximum - minimum);
output.push(if is_lab {
(raw as f64 / largest as f64 * 255.0).round() as u8
} else if let Some(indexed_maximum) = indexed_maximum {
decoded.clamp(0.0, indexed_maximum).round() as u8
} else {
(decoded.clamp(0.0, 1.0) * 255.0).round() as u8
});
}
}
Some(output)
}
fn decode_stencil_image(
stream: &Stream,
samples: &[u8],
width: usize,
height: usize,
bits_per_component: usize,
paint: &Paint,
) -> Option<Vec<u8>> {
let row_bits = width.checked_mul(bits_per_component)?;
let row_bytes = row_bits.div_ceil(8);
if samples.len() < row_bytes.checked_mul(height)? {
return None;
}
let (color, opacity) = match paint {
Paint::Solid { color, opacity } => {
(parse_pdf_hex_color(color).unwrap_or([0, 0, 0]), *opacity)
}
_ => ([0, 0, 0], 1.0),
};
let decode = stream
.dict
.get(b"Decode")
.and_then(Object::as_array)
.ok()
.map(|values| numbers(values, 2))
.filter(|values| values.len() == 2)
.unwrap_or_else(|| vec![0.0, 1.0]);
let largest = (1u64 << bits_per_component) - 1;
let mut output = Vec::with_capacity(width.checked_mul(height)?.checked_mul(4)?);
for row in 0..height {
let row_bit_start = row.checked_mul(row_bytes)?.checked_mul(8)?;
for column in 0..width {
let raw = read_bits_at(
samples,
row_bit_start.checked_add(column.checked_mul(bits_per_component)?)?,
bits_per_component,
)?;
let decoded = decode[0] + raw as f64 / largest as f64 * (decode[1] - decode[0]);
let alpha = if decoded < 0.5 {
(opacity.clamp(0.0, 1.0) * 255.0).round() as u8
} else {
0
};
output.extend_from_slice(&[color[0], color[1], color[2], alpha]);
}
}
Some(output)
}
fn read_bits_at(data: &[u8], bit_start: usize, bits: usize) -> Option<u64> {
if bits == 0 || bits > 16 || bit_start.checked_add(bits)? > data.len().checked_mul(8)? {
return None;
}
let mut value = 0u64;
for position in bit_start..bit_start + bits {
value = (value << 1) | u64::from((data[position / 8] >> (7 - position % 8)) & 1);
}
Some(value)
}
fn unblend_jpx_matte_samples(
packed: &mut [u8],
layout: JpxSampleLayout,
alpha_samples: &[i32],
maximum_sample: u32,
matte: &[f64],
) -> Result<()> {
let JpxSampleLayout {
row_bytes,
width,
height,
components,
bits,
} = layout;
let pixel_count = width
.checked_mul(height)
.ok_or_else(|| Error::LimitExceeded("PDF JPX matte pixel count overflowed".into()))?;
let required_bytes = row_bytes
.checked_mul(height)
.ok_or_else(|| Error::LimitExceeded("PDF JPX matte packed size overflowed".into()))?;
if !(1..=16).contains(&bits)
|| components == 0
|| alpha_samples.len() != pixel_count
|| matte.len() != components
|| packed.len() < required_bytes
|| maximum_sample == 0
{
return Err(Error::InvalidInput(
"PDF JPX matte dimensions or sample counts are inconsistent".into(),
));
}
let maximum = f64::from(maximum_sample);
for row in 0..height {
let row_bit_start = row
.checked_mul(row_bytes)
.and_then(|bytes| bytes.checked_mul(8))
.ok_or_else(|| Error::LimitExceeded("PDF JPX matte row offset overflowed".into()))?;
for column in 0..width {
let pixel = row * width + column;
let alpha = alpha_samples[pixel];
if !(0..=maximum_sample as i32).contains(&alpha) {
return Err(Error::InvalidInput(
"PDF JPX alpha sample is outside its declared precision".into(),
));
}
let alpha = f64::from(alpha as u32) / maximum;
for (component, matte_component) in matte.iter().copied().enumerate() {
let component_index = column
.checked_mul(components)
.and_then(|offset| offset.checked_add(component))
.ok_or_else(|| {
Error::LimitExceeded("PDF JPX matte component offset overflowed".into())
})?;
let bit_start = row_bit_start
.checked_add(component_index.checked_mul(bits).ok_or_else(|| {
Error::LimitExceeded("PDF JPX matte bit offset overflowed".into())
})?)
.ok_or_else(|| {
Error::LimitExceeded("PDF JPX matte bit offset overflowed".into())
})?;
let sample = read_bits_at(packed, bit_start, bits).ok_or_else(|| {
Error::InvalidInput("PDF JPX matte sample is truncated".into())
})?;
if sample > u64::from(maximum_sample) {
return Err(Error::InvalidInput(
"PDF JPX sample is outside its declared precision".into(),
));
}
let preblended = sample as f64 / maximum;
let unblended = if alpha == 0.0 {
0.0
} else {
matte_component + (preblended - matte_component) / alpha
};
let unblended = (unblended.clamp(0.0, 1.0) * maximum).round() as u32;
for bit in 0..bits {
let position = bit_start + bit;
let bit_mask = 1 << (7 - position % 8);
let sample_bit = ((unblended >> (bits - bit - 1)) & 1) as u8;
if sample_bit == 0 {
packed[position / 8] &= !bit_mask;
} else {
packed[position / 8] |= bit_mask;
}
}
}
}
}
Ok(())
}
fn parse_pdf_hex_color(color: &str) -> Option<[u8; 3]> {
let color = color.trim_start_matches('#');
if color.len() != 6 {
return None;
}
Some([
u8::from_str_radix(&color[0..2], 16).ok()?,
u8::from_str_radix(&color[2..4], 16).ok()?,
u8::from_str_radix(&color[4..6], 16).ok()?,
])
}
fn resample_image_with_alpha(
color: &[u8],
color_dimensions: (usize, usize),
components: usize,
alpha: &[u8],
alpha_dimensions: (usize, usize),
target_dimensions: (usize, usize),
) -> Vec<u8> {
let (color_width, color_height) = color_dimensions;
let (alpha_width, alpha_height) = alpha_dimensions;
let (target_width, target_height) = target_dimensions;
let pixel_count = target_width.saturating_mul(target_height);
let mut output = Vec::with_capacity(pixel_count.saturating_mul(components + 1));
let color_width = color_width.max(1);
let color_height = color_height.max(1);
let alpha_width = alpha_width.max(1);
let alpha_height = alpha_height.max(1);
for y in 0..target_height {
let color_y = (y.saturating_mul(color_height) / target_height.max(1)).min(color_height - 1);
let alpha_y = (y.saturating_mul(alpha_height) / target_height.max(1)).min(alpha_height - 1);
for x in 0..target_width {
let color_x =
(x.saturating_mul(color_width) / target_width.max(1)).min(color_width - 1);
let alpha_x =
(x.saturating_mul(alpha_width) / target_width.max(1)).min(alpha_width - 1);
let color_start =
(color_y.saturating_mul(color_width) + color_x).saturating_mul(components);
match color.get(color_start..color_start.saturating_add(components)) {
Some(pixel) => output.extend_from_slice(pixel),
None => output.extend(std::iter::repeat_n(0u8, components)),
}
output.push(
alpha
.get(alpha_y.saturating_mul(alpha_width) + alpha_x)
.copied()
.unwrap_or(0),
);
}
}
output
}
fn clean_font_name(name: &[u8]) -> String {
let name = String::from_utf8_lossy(name);
let name = name
.split_once('+')
.map_or(name.as_ref(), |(_, value)| value);
match name {
"ArialMT" => "Arial".into(),
"Arial-BoldMT" => "Arial".into(),
"Arial-ItalicMT" => "Arial".into(),
"Arial-BoldItalicMT" => "Arial".into(),
_ => name.replace([',', '-'], " "),
}
}
fn stable_pdf_symbol_fallback<'a>(family: &str, text: &'a str) -> Option<&'a str> {
let normalized = family
.chars()
.filter(|character| !character.is_whitespace())
.flat_map(char::to_lowercase)
.collect::<String>();
if !normalized.starts_with("wingdings") {
return None;
}
match text {
"\u{26ab}" => Some("\u{25cf}"),
"\u{25c6}" | "\u{2713}" | "\u{27a2}" => Some(text),
_ => None,
}
}
fn should_outline_pdf_text(
requires_outline: bool,
has_embedded_font: bool,
explicit_fidelity_mode: bool,
) -> bool {
requires_outline || (has_embedded_font && explicit_fidelity_mode)
}
fn pdf_font_stack(family: &str) -> String {
if family.contains(',') {
return family.to_owned();
}
let normalized = family
.chars()
.filter(|character| !character.is_whitespace() && *character != '-')
.flat_map(char::to_lowercase)
.collect::<String>();
if normalized.contains("meiryo")
|| normalized.contains("msgothic")
|| normalized.contains("yugothic")
|| normalized.contains("gothic")
{
let hiragino = if normalized.contains("bold") {
"Hiragino Sans W6"
} else {
"Hiragino Sans W3"
};
return format!(
"'{family}', '{hiragino}', 'Hiragino Sans', 'Yu Gothic', YuGothic, sans-serif"
);
}
if normalized.contains("mincho") {
return format!("'{family}', 'Hiragino Mincho ProN', 'Yu Mincho', serif");
}
if normalized.contains("arial")
|| normalized.contains("calibri")
|| normalized.contains("trebuchet")
{
return format!("'{family}', Arial, Verdana, sans-serif");
}
family.to_owned()
}
fn is_browser_font_family(family: &str) -> bool {
let family = family.to_ascii_lowercase();
[
"arial",
"helvetica",
"times",
"courier",
"calibri",
"cambria",
"verdana",
"georgia",
"tahoma",
"trebuchet",
"meiryo",
"yu gothic",
"yugothic",
"hiragino",
"noto sans",
"noto serif",
]
.iter()
.any(|candidate| family.contains(candidate))
}
fn fmt(value: f64) -> String {
format!("{value:.5}")
.trim_end_matches('0')
.trim_end_matches('.')
.to_owned()
}
fn pattern_variant_id(source_id: &str, transform: Matrix) -> String {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for byte in source_id.bytes().chain(
transform
.iter()
.flat_map(|value| value.to_bits().to_be_bytes()),
) {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("pdf-pattern-variant-{hash:016x}")
}
fn deduplicate(values: Vec<String>) -> Vec<String> {
let mut result = Vec::new();
for value in values {
if !result.contains(&value) {
result.push(value);
}
}
result
}
const MAX_RECOVERED_OBJECTS: usize = 500_000;
#[cfg(test)]
fn load_document(
path: &Path,
max_stream_bytes: u64,
max_input_bytes: u64,
) -> Result<(Document, Option<String>)> {
let bytes = read_limited_file(path, max_input_bytes, "PDF input")?;
load_document_bytes(&bytes, max_stream_bytes, max_input_bytes)
}
fn load_document_bytes(
bytes: &[u8],
max_stream_bytes: u64,
max_input_bytes: u64,
) -> Result<(Document, Option<String>)> {
if bytes.len() as u64 > max_input_bytes {
return Err(Error::LimitExceeded(format!(
"PDF input exceeds maximum limit of {max_input_bytes} bytes"
)));
}
let options = lopdf::LoadOptions {
max_decompressed_size: usize::try_from(max_stream_bytes).ok(),
..Default::default()
};
let error = match Document::load_mem_with_options(bytes, options.clone()) {
Ok(document) => return Ok((document, None)),
Err(error) => error,
};
if contains_pdf_name(bytes, b"Encrypt") {
return Err(error.into());
}
let Some(rebuilt) = rebuild_cross_references(bytes) else {
return Err(error.into());
};
let warning = format!(
"PDF cross-reference table could not be parsed ({error}); it was rebuilt by scanning the file for indirect objects"
);
let document = Document::load_mem_with_options(&rebuilt, options).map_err(|_| error)?;
Ok((document, Some(warning)))
}
fn rebuild_cross_references(bytes: &[u8]) -> Option<Vec<u8>> {
let mut offsets: HashMap<u32, (u16, usize)> = HashMap::new();
let mut index = 0usize;
while let Some(found) = find(&bytes[index..], b"obj") {
let position = index + found;
index = position + 3;
if bytes
.get(position + 3)
.is_some_and(|byte| !is_pdf_delimiter(*byte))
{
continue;
}
let Some((number, generation, start)) = parse_object_header(bytes, position) else {
continue;
};
if number == 0 || number as usize > MAX_RECOVERED_OBJECTS {
return None;
}
offsets.insert(number, (generation, start));
if offsets.len() > MAX_RECOVERED_OBJECTS {
return None;
}
}
let highest = *offsets.keys().max()?;
let root = find_catalog(bytes, &offsets)?;
let root_generation = offsets.get(&root)?.0;
let size = highest.checked_add(1)?;
let mut output = bytes.to_vec();
if !output.ends_with(b"\n") {
output.push(b'\n');
}
let xref_start = output.len();
output.extend_from_slice(b"xref\n");
output.extend_from_slice(format!("0 {size}\n").as_bytes());
output.extend_from_slice(b"0000000000 65535 f \n");
for number in 1..=highest {
match offsets.get(&number) {
Some((generation, offset)) => {
output.extend_from_slice(format!("{offset:010} {generation:05} n \n").as_bytes())
}
None => output.extend_from_slice(b"0000000000 65535 f \n"),
}
}
output.extend_from_slice(
format!("trailer\n<< /Size {size} /Root {root} {root_generation} R >>\nstartxref\n{xref_start}\n%%EOF\n")
.as_bytes(),
);
Some(output)
}
fn parse_object_header(bytes: &[u8], keyword: usize) -> Option<(u32, u16, usize)> {
let mut cursor = skip_whitespace_back(bytes, keyword)?;
let generation_end = cursor;
while cursor > 0 && bytes[cursor - 1].is_ascii_digit() {
cursor -= 1;
}
if cursor == generation_end {
return None;
}
let generation = std::str::from_utf8(&bytes[cursor..generation_end])
.ok()?
.parse::<u16>()
.ok()?;
let mut cursor = skip_whitespace_back(bytes, cursor)?;
let number_end = cursor;
while cursor > 0 && bytes[cursor - 1].is_ascii_digit() {
cursor -= 1;
}
if cursor == number_end {
return None;
}
if cursor > 0 && !is_pdf_delimiter(bytes[cursor - 1]) {
return None;
}
let number = std::str::from_utf8(&bytes[cursor..number_end])
.ok()?
.parse::<u32>()
.ok()?;
Some((number, generation, cursor))
}
fn skip_whitespace_back(bytes: &[u8], position: usize) -> Option<usize> {
let mut cursor = position;
let start = cursor;
while cursor > 0 && bytes[cursor - 1].is_ascii_whitespace() {
cursor -= 1;
}
(cursor != start).then_some(cursor)
}
fn find_catalog(bytes: &[u8], offsets: &HashMap<u32, (u16, usize)>) -> Option<u32> {
let marker = find(bytes, b"/Catalog")?;
offsets
.iter()
.filter(|(_, (_, offset))| *offset <= marker)
.max_by_key(|(_, (_, offset))| *offset)
.map(|(number, _)| *number)
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn contains_pdf_name(bytes: &[u8], name: &[u8]) -> bool {
bytes.split(|byte| *byte == b'/').skip(1).any(|part| {
let end = part
.iter()
.position(|byte| is_pdf_delimiter(*byte))
.unwrap_or(part.len());
let token = &part[..end];
let mut index = 0;
let mut matched = 0;
while index < token.len() {
let value = if token[index] == b'#' {
let Some(pair) = token.get(index + 1..index + 3) else {
return false;
};
let Some(high) = char::from(pair[0]).to_digit(16) else {
return false;
};
let Some(low) = char::from(pair[1]).to_digit(16) else {
return false;
};
index += 3;
(high * 16 + low) as u8
} else {
let byte = token[index];
index += 1;
byte
};
if name.get(matched) != Some(&value) {
return false;
}
matched += 1;
}
matched == name.len()
})
}
#[cfg(test)]
mod font_test_data;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unblends_packed_jpx_matte_samples_at_each_supported_precision() {
for components in [1usize, 3] {
for bits in [1usize, 2, 4, 8, 16] {
let maximum = (1u32 << bits) - 1;
let alpha = if bits == 1 { maximum } else { maximum / 2 };
let row_bytes = (components * bits).div_ceil(8);
let mut packed = vec![0u8; row_bytes];
for component in 0..components {
for bit in 0..bits {
if (alpha >> (bits - bit - 1)) & 1 != 0 {
let position = component * bits + bit;
packed[position / 8] |= 1 << (7 - position % 8);
}
}
}
let matte = vec![0.0; components];
unblend_jpx_matte_samples(
&mut packed,
JpxSampleLayout {
row_bytes,
width: 1,
height: 1,
components,
bits,
},
&[alpha as i32],
maximum,
&matte,
)
.unwrap();
for component in 0..components {
assert_eq!(
read_bits_at(&packed, component * bits, bits),
Some(u64::from(maximum))
);
}
}
}
}
#[test]
fn recovery_rejects_huge_object_numbers_before_expanding_xref() {
let input = b"%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n4294967294 0 obj <<>> endobj";
assert!(rebuild_cross_references(input).is_none());
}
#[test]
fn recovery_preserves_object_generations() {
let input = b"%PDF-1.4\n1 7 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n";
let recovered = rebuild_cross_references(input).unwrap();
let document = Document::load_mem(&recovered).unwrap();
assert_eq!(
document
.trailer
.get(b"Root")
.unwrap()
.as_reference()
.unwrap(),
(1, 7)
);
assert!(document.get_object((1, 7)).is_ok());
}
#[test]
fn recovery_detects_escaped_encryption_names() {
for input in [
b"/Encrypt 9 0 R".as_slice(),
b"/E#6ecrypt 9 0 R",
b"/#45ncrypt 9 0 R",
] {
assert!(contains_pdf_name(input, b"Encrypt"));
}
assert!(!contains_pdf_name(b"/EncryptionInfo", b"Encrypt"));
}
#[test]
fn invalid_pdf_recovery_honors_the_bounded_source_read() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("oversized.pdf");
std::fs::write(&path, b"not a pdf and larger than the configured budget").unwrap();
assert!(matches!(
load_document(&path, 1024, 8),
Err(Error::LimitExceeded(_))
));
}
fn test_font_decoder(data: Vec<u8>) -> FontDecoder {
FontDecoder {
family: "Synthetic Font".into(),
bold: false,
italic: false,
requires_outline: true,
font_data: Some(data.into()),
glyph_names: HashMap::new(),
unicode_map: HashMap::new(),
code_lengths: vec![1],
fallback_kind: FontFallback::OneByte,
widths: HashMap::from([(65, 500.0), (32, 250.0)]),
default_width: 500.0,
type3: None,
type1: Type1Cache::default(),
identity_cid: false,
cid_to_gid_map: None,
}
}
#[test]
fn outlines_true_type_in_font_units_with_pdf_advances_and_spaces() {
let decoder = test_font_decoder(font_test_data::font(None, false, false));
let path = decoder.outline_path(b"A A", 0.1, 0.2).unwrap();
assert!(path.contains("M 0 0"), "{path}");
assert!(path.contains("L 0.5 0"), "{path}");
assert!(path.contains("M 1.15 0"), "{path}");
assert_eq!(path.matches('M').count(), 2);
assert_eq!(decoder.outline_path(b" ", 0.0, 0.0).unwrap(), "");
}
#[test]
fn outlines_true_type_curves_and_composites() {
let decoder = test_font_decoder(font_test_data::font(None, false, true));
assert!(decoder.outline_path(b"A", 0.0, 0.0).unwrap().contains('Q'));
let path = decoder.outline_path(b"B", 0.0, 0.0).unwrap();
assert!(path.contains("M 0.1 0.2"), "{path}");
}
#[test]
fn outlines_a_glyph_by_post_table_name_when_the_font_has_no_cmap() {
let mut decoder = test_font_decoder(font_test_data::font_without_cmap());
decoder.glyph_names.insert(65, "TestGlyph".into());
let path = decoder.outline_path(b"A", 0.0, 0.0).unwrap();
assert!(path.contains("M 0 0"), "{path}");
assert!(path.matches('L').count() >= 2, "{path}");
}
#[test]
fn retains_pdf_character_code_cmap_fallback() {
let mut decoder = test_font_decoder(font_test_data::font(None, false, false));
decoder.unicode_map.insert(vec![65], "Z".into());
assert!(
decoder
.outline_path(b"A", 0.0, 0.0)
.unwrap()
.contains("L 0.5 0")
);
decoder.unicode_map.insert(vec![65], "\u{ffff}".into());
assert!(
decoder
.outline_path(b"A", 0.0, 0.0)
.unwrap()
.contains("L 0.5 0")
);
}
#[test]
fn prefers_raw_pdf_code_over_to_unicode_value_for_symbolic_font_glyph_selection() {
let mut decoder = test_font_decoder(font_test_data::font(None, false, false));
decoder.unicode_map.insert(vec![66], "A".into());
let path = decoder.outline_path(b"B", 0.0, 0.0).unwrap();
assert!(path.contains("M 0.1 0.2"), "{path}");
assert!(!path.contains("L 0.5 0"), "{path}");
}
#[test]
fn outlines_a_symbolic_true_type_glyph_whose_code_has_no_tounicode_entry() {
let mut decoder = test_font_decoder(font_test_data::font(None, false, false));
decoder.unicode_map.insert(vec![66], "B".into());
let path = decoder.outline_path(b"A", 0.0, 0.0).unwrap();
assert!(path.contains("L 0.5 0"), "{path}");
}
#[test]
fn malformed_font_and_missing_glyph_return_errors() {
let decoder = test_font_decoder(font_test_data::font(None, true, false));
assert!(decoder.outline_path(b"A", 0.0, 0.0).is_err());
let decoder = test_font_decoder(font_test_data::font(None, false, false));
assert!(decoder.outline_path(b"Z", 0.0, 0.0).is_err());
let decoder = test_font_decoder(vec![0, 1, 2]);
assert!(decoder.outline_path(b"A", 0.0, 0.0).is_err());
}
#[test]
fn outlines_opentype_cff_and_retains_raw_cff_fallback() {
let decoder =
test_font_decoder(font_test_data::font(Some(minimal_test_cff()), false, false));
let path = decoder.outline_path(b"A", 0.0, 0.0).unwrap();
assert!(path.contains("M 0 0"), "{path}");
assert!(
path.contains("L 0.1 0") && path.contains("L 0.05 0.1") && path.ends_with('Z'),
"{path}"
);
let mut decoder = test_font_decoder(minimal_test_cff());
decoder.glyph_names.insert(65, "A".into());
assert!(
decoder
.outline_path(b"A", 0.0, 0.0)
.unwrap()
.contains("M 0 0")
);
}
#[test]
fn normalizes_known_wingdings_bullet_without_emoji_metrics() {
assert_eq!(
stable_pdf_symbol_fallback("Wingdings Regular", "\u{26ab}"),
Some("\u{25cf}")
);
assert_eq!(stable_pdf_symbol_fallback("Arial", "\u{26ab}"), None);
assert_eq!(stable_pdf_symbol_fallback("Wingdings", "A"), None);
assert_eq!(
stable_pdf_symbol_fallback("Wingdings", "\u{27a2}"),
Some("\u{27a2}")
);
}
#[test]
fn embedded_font_outlining_requires_explicit_fidelity_mode() {
assert!(should_outline_pdf_text(true, false, false));
assert!(!should_outline_pdf_text(false, true, false));
assert!(should_outline_pdf_text(false, true, true));
assert!(!should_outline_pdf_text(false, false, true));
}
#[test]
fn supplies_script_appropriate_pdf_font_fallbacks() {
assert_eq!(
pdf_font_stack("Meiryo Bold"),
"'Meiryo Bold', 'Hiragino Sans W6', 'Hiragino Sans', 'Yu Gothic', YuGothic, sans-serif"
);
assert_eq!(
pdf_font_stack("MeiryoUI"),
"'MeiryoUI', 'Hiragino Sans W3', 'Hiragino Sans', 'Yu Gothic', YuGothic, sans-serif"
);
assert_eq!(
pdf_font_stack("MS Mincho"),
"'MS Mincho', 'Hiragino Mincho ProN', 'Yu Mincho', serif"
);
assert_eq!(pdf_font_stack("Custom Font"), "Custom Font");
}
#[test]
fn user_unit_is_page_local_and_rejects_invalid_values() {
let mut document = Document::with_version("1.7");
let parent_id = document.add_object(dictionary! { "UserUnit" => 2 });
let page_id = document.add_object(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(parent_id),
});
assert_eq!(page_user_unit(&document, page_id).unwrap(), 1.0);
document
.get_object_mut(page_id)
.unwrap()
.as_dict_mut()
.unwrap()
.set("UserUnit", 0);
assert!(page_user_unit(&document, page_id).is_err());
}
#[test]
fn expands_shared_mesh_decode_component_range() {
let decode = [-10.0, 20.0, -5.0, 15.0, 0.0, 1.0];
assert_eq!(
complete_mesh_decode_ranges(&decode, 3),
Some(vec![-10.0, 20.0, -5.0, 15.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0])
);
}
#[test]
fn rejects_mesh_decode_without_complete_component_pair() {
assert!(complete_mesh_decode_ranges(&[0.0, 1.0, 0.0, 1.0], 3).is_none());
assert!(complete_mesh_decode_ranges(&[0.0, 1.0, 0.0, 1.0, 0.0], 3).is_none());
}
use lopdf::dictionary;
#[test]
fn parses_simple_to_unicode_map() {
let map = parse_to_unicode_cmap(
b"1 beginbfchar\n<01> <0041>\nendbfchar\n1 beginbfrange\n<02> <03> <0042>\nendbfrange",
);
assert_eq!(map.get(&vec![1]).map(String::as_str), Some("A"));
assert_eq!(map.get(&vec![2]).map(String::as_str), Some("B"));
assert_eq!(map.get(&vec![3]).map(String::as_str), Some("C"));
}
#[test]
fn expands_pdf_short_curve_operators_to_svg_cubics() {
let mut path = PathBuilder::default();
path.command("M", &[1.0, 2.0]);
let [x2, y2, x3, y3] = [3.0, 4.0, 5.0, 6.0];
let (current_x, current_y) = path.current_point.unwrap();
path.command("C", &[current_x, current_y, x2, y2, x3, y3]);
path.command("C", &[7.0, 8.0, 9.0, 10.0, 9.0, 10.0]);
assert_eq!(path.data, "M 1 2 C 1 2 3 4 5 6 C 7 8 9 10 9 10");
}
#[test]
fn executes_multi_input_pdf_calculator_function() {
let result =
evaluate_calculator_function(b"{ pop dup 1 exch sub 0.25 }", &[0.25, 0.75]).unwrap();
assert_eq!(result, vec![0.25, 0.75, 0.25]);
}
#[test]
fn executes_pdf_calculator_conditionals_and_stack_ops() {
let result = evaluate_calculator_function(
b"{ dup 0.5 gt { dup 2 mul } { dup 4 mul } ifelse }",
&[0.75],
)
.unwrap();
assert_eq!(result, vec![0.75, 1.5]);
}
#[test]
fn interpolates_multi_output_sampled_pdf_function() {
let mut document = Document::with_version("1.7");
let function_id = document.add_object(Stream::new(
lopdf::dictionary! {
"FunctionType" => 0,
"Domain" => vec![0.into(), 1.into()],
"Range" => vec![0.into(), 1.into(), 0.into(), 1.into()],
"Size" => vec![2.into()],
"BitsPerSample" => 8,
},
vec![0, 255, 255, 0],
));
let result =
evaluate_pdf_function(&document, &Object::Reference(function_id), 0.25, 0).unwrap();
assert!((result[0] - 0.25).abs() < 1e-12);
assert!((result[1] - 0.75).abs() < 1e-12);
}
#[test]
fn interpolates_multi_input_sampled_pdf_function() {
let mut document = Document::with_version("1.7");
let function_id = document.add_object(Stream::new(
lopdf::dictionary! {
"FunctionType" => 0,
"Domain" => vec![0.into(), 1.into(), 0.into(), 1.into()],
"Range" => vec![0.into(), 1.into()],
"Size" => vec![2.into(), 2.into()],
"BitsPerSample" => 8,
},
vec![0, 64, 128, 255],
));
let result = evaluate_pdf_function_inputs(
&document,
&Object::Reference(function_id),
&[0.5, 0.5],
0,
)
.unwrap();
assert!((result[0] - 111.75 / 255.0).abs() < 1e-12);
}
#[test]
fn outlines_raw_cff_type2_glyph() {
let decoder = FontDecoder {
family: "Test CFF".into(),
bold: false,
italic: false,
requires_outline: true,
font_data: None,
glyph_names: HashMap::from([(65, "A".into())]),
unicode_map: HashMap::from([(vec![65], "A".into())]),
code_lengths: vec![1],
fallback_kind: FontFallback::OneByte,
widths: HashMap::from([(65, 500.0)]),
default_width: 500.0,
type3: None,
type1: Type1Cache::default(),
identity_cid: false,
cid_to_gid_map: None,
};
let path = decoder
.outline_cff(&minimal_test_cff(), b"A", 0.0, 0.0)
.unwrap();
assert!(path.contains("M 0 0"));
assert!(path.matches('L').count() >= 3);
}
#[test]
fn outlines_a_cff_glyph_whose_code_has_no_tounicode_entry() {
let decoder = FontDecoder {
family: "Test CFF Gap".into(),
bold: false,
italic: false,
requires_outline: true,
font_data: None,
glyph_names: HashMap::from([(65, "A".into())]),
unicode_map: HashMap::from([(vec![66], "B".into())]),
code_lengths: vec![1],
fallback_kind: FontFallback::OneByte,
widths: HashMap::from([(65, 500.0)]),
default_width: 500.0,
type3: None,
type1: Type1Cache::default(),
identity_cid: false,
cid_to_gid_map: None,
};
let path = decoder
.outline_cff(&minimal_test_cff(), b"A", 0.0, 0.0)
.unwrap();
assert!(path.contains("M 0 0"));
assert!(path.matches('L').count() >= 3);
}
#[test]
fn outlines_a_cid_keyed_cff_glyph_under_identity_h() {
let decoder = FontDecoder {
family: "Test CID CFF".into(),
bold: false,
italic: false,
requires_outline: true,
font_data: None,
glyph_names: HashMap::new(),
unicode_map: HashMap::new(),
code_lengths: vec![2],
fallback_kind: FontFallback::Utf16Be,
widths: HashMap::from([(1, 500.0)]),
default_width: 500.0,
type3: None,
type1: Type1Cache::default(),
identity_cid: true,
cid_to_gid_map: None,
};
let path = decoder
.outline_cff(&minimal_test_cid_cff(), &[0x00, 0x01], 0.0, 0.0)
.unwrap();
assert!(path.contains("M 0 0"), "{path}");
assert!(path.matches('L').count() >= 2, "{path}");
}
#[test]
fn outlines_a_cff_glyph_composed_via_the_deprecated_seac_mechanism() {
let decoder = FontDecoder {
family: "Test Seac CFF".into(),
bold: false,
italic: false,
requires_outline: true,
font_data: None,
glyph_names: HashMap::from([(69, "Eacute".into())]),
unicode_map: HashMap::from([(vec![69], "Eacute".into())]),
code_lengths: vec![1],
fallback_kind: FontFallback::OneByte,
widths: HashMap::from([(69, 500.0)]),
default_width: 500.0,
type3: None,
type1: Type1Cache::default(),
identity_cid: false,
cid_to_gid_map: None,
};
let path = decoder
.outline_cff(&minimal_test_seac_cff(), b"E", 0.0, 0.0)
.unwrap();
assert!(path.contains("M 0 0"), "{path}");
assert!(path.contains("M 0.15 0.15"), "{path}");
}
#[test]
fn converts_calgray_to_a_neutral_gamma_encoded_srgb_value() {
let document = Document::with_version("1.7");
let color_space = Object::Array(vec![
Object::Name(b"CalGray".to_vec()),
dictionary! {
"WhitePoint" => vec![Object::Integer(1), Object::Integer(1), Object::Integer(1)],
"Gamma" => Object::Integer(1),
}
.into(),
]);
let [r, g, b] = components_to_rgb(&document, Some(&color_space), &[0.5], 0).unwrap();
assert_eq!(r, g);
assert_eq!(g, b);
let expected = 1.055 * 0.5f64.powf(1.0 / 2.4) - 0.055;
assert!((r - expected).abs() < 1e-9, "r={r} expected={expected}");
}
#[test]
fn repairs_allowed_damaged_group3_2d_row() {
let spec = InlineImageSpec {
width: 8,
height: 4,
bits: 1,
color_space: "/G".into(),
filter: Some("/CCF".into()),
k: 2,
columns: 8,
rows: 4,
end_of_block: true,
end_of_line: true,
byte_aligned: false,
black_is_one: false,
damaged_rows_before_error: 1,
interpolate: false,
image_mask: false,
};
let encoded = [
0x00, 0x1d, 0xb0, 0x01, 0x60, 0x02, 0x00, 0x00, 0x00, 0xe6, 0x00, 0x20, 0x02, 0x00,
0x20, 0x02, 0x00, 0x20, 0x02,
];
let decoded = spec.decode_ccitt(&encoded).unwrap();
assert_eq!(&decoded[0..8], &[255, 255, 255, 255, 0, 0, 0, 0]);
assert_eq!(&decoded[8..16], &[255, 255, 255, 255, 0, 0, 0, 0]);
assert!(decoded[16..32].iter().all(|pixel| *pixel == 255));
}
#[test]
fn decodes_external_ccitt_stream_parameters() {
let encoded = vec![
0x00, 0x1d, 0xb0, 0x01, 0x60, 0x02, 0x00, 0x00, 0x00, 0xe6, 0x00, 0x20, 0x02, 0x00,
0x20, 0x02, 0x00, 0x20, 0x02,
];
let stream = Stream::new(
lopdf::dictionary! {
"Width" => 8,
"Height" => 4,
"Filter" => "CCITTFaxDecode",
"DecodeParms" => lopdf::dictionary! {
"K" => 2,
"Columns" => 8,
"Rows" => 4,
"EndOfBlock" => true,
"EndOfLine" => true,
"DamagedRowsBeforeError" => 1,
},
},
encoded,
);
let spec = ccitt_spec_from_stream(&stream, 8, 4);
let decoded = spec.decode_ccitt(&stream.content).unwrap();
assert_eq!(&decoded[0..8], &[255, 255, 255, 255, 0, 0, 0, 0]);
assert_eq!(decoded.len(), 32);
}
#[test]
fn unpacks_subbyte_image_rows_and_stencil_alpha() {
let grayscale = Stream::new(
lopdf::dictionary! { "Decode" => vec![0.into(), 1.into()] },
Vec::new(),
);
let samples = normalize_image_samples(
&grayscale,
&[0x08, 0xF0, 0xF8, 0x00],
3,
2,
4,
1,
Some(&Object::Name(b"DeviceGray".to_vec())),
)
.unwrap();
assert_eq!(samples, vec![0, 136, 255, 255, 136, 0]);
let stencil = Stream::new(
lopdf::dictionary! { "Decode" => vec![0.into(), 1.into()] },
Vec::new(),
);
let rgba = decode_stencil_image(
&stencil,
&[0b1010_1010],
8,
1,
1,
&Paint::Solid {
color: "#00FF00".into(),
opacity: 0.5,
},
)
.unwrap();
assert_eq!(&rgba[0..4], &[0, 255, 0, 0]);
assert_eq!(&rgba[4..8], &[0, 255, 0, 128]);
}
fn minimal_test_cff() -> Vec<u8> {
vec![
1, 0, 4, 4, 0, 1, 1, 1, 5, b'T', b'e', b's', b't', 0, 1, 1, 1, 7, 188, 16, 185, 15, 167, 17, 0, 0, 0, 0, 0, 2, 1, 1, 2, 13, 14, 139, 139, 21, 239, 139, 89, 239, 89, 39, 5, 14, 0, 0, 34, 0, 1, 65, ]
}
fn minimal_test_cid_cff() -> Vec<u8> {
vec![
1, 0, 4, 4, 0, 1, 1, 1, 5, b'T', b'e', b's', b't', 0, 1, 1, 1, 24, 139, 139, 139, 12, 30, 28, 0, 63, 15, 28, 0, 45, 17, 28, 0, 66, 12, 36, 28, 0, 71, 12, 37, 0, 0, 0, 0, 0, 2, 1, 1, 2, 13, 14, 139, 139, 21, 239, 139, 89, 239, 89, 39, 5, 14, 0, 0, 1,
0, 1, 1, 1, 1, 0, 0, 0,
]
}
fn minimal_test_seac_cff() -> Vec<u8> {
vec![
1, 0, 4, 4, 0, 1, 1, 1, 5, b'T', b'e', b's', b't', 0, 1, 1, 1, 5, 212, 15, 182, 17, 0, 3, 1, 1, 2, 7, 13, b'E', b'a', b'c', b'u', b't', b'e', b'E', b'a', b'c', b'u', b't',
b'e', 0, 0, 0, 4, 1, 1, 2, 13, 17, 23, 14, 139, 139, 21, 239, 139, 89, 239, 89, 39, 5, 14, 189, 189, 21, 14, 239, 239, 208, 247, 86, 14, 0, 1, 135, 1, 136, 1, 137,
]
}
}