Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions src-tauri/src/pdf_engine/source_content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ struct Flags {
ambiguous: bool,
subset_font: bool,
custom_encoding: bool,
text_rise: bool,
text_render_mode: bool,
masked: bool,
shared: bool,
geometry: bool,
Expand Down Expand Up @@ -201,6 +203,8 @@ struct GState {
hscale: f64,
tc: f64,
tw: f64,
text_rise: f64,
text_render_mode: u8,
}

impl Default for GState {
Expand All @@ -218,6 +222,8 @@ impl Default for GState {
hscale: 100.0,
tc: 0.0,
tw: 0.0,
text_rise: 0.0,
text_render_mode: 0,
}
}
}
Expand All @@ -235,13 +241,15 @@ impl GState {
struct TextState {
tm: [f64; 6],
tlm: [f64; 6],
clip_pending: bool,
}

impl Default for TextState {
fn default() -> Self {
Self {
tm: IDENTITY,
tlm: IDENTITY,
clip_pending: false,
}
}
}
Expand Down Expand Up @@ -396,8 +404,14 @@ impl Walker<'_> {
"BT" => {
ts.tm = IDENTITY;
ts.tlm = IDENTITY;
ts.clip_pending = false;
}
"ET" => {
if ts.clip_pending {
gs.clip_active = true;
ts.clip_pending = false;
}
}
"ET" => {}
"Tf" => {
if let Some(name) = op.operands.first().and_then(|o| o.as_name().ok()) {
gs.font_name = Some(name.to_vec());
Expand All @@ -421,6 +435,28 @@ impl Walker<'_> {
gs.hscale = z;
}
}
"Ts" => {
gs.text_rise = match op.operands.as_slice() {
[value] => obj_f64(value).ok_or_else(|| {
malformed_content("A text-rise operator has an invalid operand.")
})?,
_ => {
return Err(malformed_content(
"A text-rise operator must have one operand.",
))
}
};
}
"Tr" => {
gs.text_render_mode = match op.operands.as_slice() {
[Object::Integer(mode)] if (0..=7).contains(mode) => *mode as u8,
_ => {
return Err(malformed_content(
"A text-rendering-mode operator must contain an integer from 0 to 7.",
))
}
};
}
"TL" => {
if let Some(l) = op.operands.first().and_then(obj_f64) {
gs.leading = l;
Expand Down Expand Up @@ -480,6 +516,9 @@ impl Walker<'_> {
nested,
geom_unsafe,
)?;
if gs.text_render_mode >= 4 && pieces.iter().any(|piece| !piece.is_empty()) {
ts.clip_pending = true;
}
}
"Do" => {
let Some(name) = op.operands.first().and_then(|o| o.as_name().ok()) else {
Expand Down Expand Up @@ -611,6 +650,7 @@ impl Walker<'_> {
let shown = inspect.width * th;
let width = (shown * sx).abs().max(0.01);
let tx = (inspect.width + spacing_advance(pieces, gs.tc, gs.tw)) * th;
let origin = apply_point(effective, 0.0, gs.text_rise);
let flags = Flags {
nested_form: nested,
type3: inspect.type3,
Expand All @@ -624,6 +664,8 @@ impl Walker<'_> {
ambiguous: inspect.ambiguous,
subset_font: inspect.subset_font,
custom_encoding: inspect.custom_encoding,
text_rise: gs.text_rise != 0.0,
text_render_mode: gs.text_render_mode != 0,
masked: gs.masked,
geometry: geom_unsafe,
..Flags::default()
Expand All @@ -632,8 +674,8 @@ impl Walker<'_> {
page_index,
kind: SourceKind::Text,
rect: SourceRect {
x: effective[4],
y: effective[5],
x: origin.0,
y: origin.1,
w: width,
h: height,
},
Expand Down Expand Up @@ -808,6 +850,10 @@ fn pick_reason(flags: &Flags) -> (SourceCapability, Option<String>) {
Some("SUBSET_FONT")
} else if flags.custom_encoding {
Some("CUSTOM_ENCODING")
} else if flags.text_rise {
Some("TEXT_RISE")
} else if flags.text_render_mode {
Some("TEXT_RENDER_MODE")
} else if flags.masked {
Some("MASKED_IMAGE")
} else if flags.shared {
Expand Down
72 changes: 72 additions & 0 deletions src-tauri/src/pdf_engine/source_content_integ.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const FROZEN_REASONS: &[&str] = &[
"MISSING_FONT",
"SUBSET_FONT",
"CUSTOM_ENCODING",
"TEXT_RISE",
"TEXT_RENDER_MODE",
"NO_TOUNICODE",
"AMBIGUOUS_UNICODE",
"TYPE3",
Expand Down Expand Up @@ -853,6 +855,76 @@ fn classify_named_standard_encoding_remains_supported() {
);
}

#[test]
fn classify_text_rise_moves_bounds_and_resets_to_supported() {
let scratch = Scratch::new("review-text-rise");
let path = scratch.file("text-rise.pdf");
write_helvetica_page(
&path,
b"BT /F1 12 Tf 72 700 Td 20 Ts (Hi) Tj 0 Ts (Lo) Tj ET",
);
let hits = classify(&path, "REVIEW-TEXT-RISE");
let texts: Vec<&SourceOccurrence> = hits.iter().filter(|hit| kind_token(hit) == "text").collect();
assert_eq!(texts.len(), 2);
assert_unsupported(texts[0], "text", "TEXT_RISE", "REVIEW-TEXT-RISE");
assert!((texts[0].rect.y - 720.0).abs() <= 1.0);
assert_supported_text_or_image(texts[1], "text", "REVIEW-TEXT-RISE-RESET");
assert!((texts[1].rect.y - 700.0).abs() <= 1.0);
}

#[test]
fn classify_text_render_mode_applies_clip_only_after_text_object() {
let scratch = Scratch::new("review-text-render-mode");
let path = scratch.file("text-render-mode.pdf");
write_helvetica_page(
&path,
concat!(
"BT /F1 12 Tf 72 700 Td 7 Tr (A) Tj 0 Tr (B) Tj ET ",
"BT /F1 12 Tf 72 650 Td (C) Tj ET"
)
.as_bytes(),
);
let hits = classify(&path, "REVIEW-TEXT-RENDER-MODE");
let texts: Vec<&SourceOccurrence> = hits.iter().filter(|hit| kind_token(hit) == "text").collect();
assert_eq!(texts.len(), 3);
assert_unsupported(
texts[0],
"text",
"TEXT_RENDER_MODE",
"REVIEW-TEXT-RENDER-MODE",
);
assert_supported_text_or_image(texts[1], "text", "REVIEW-TEXT-RENDER-MODE-RESET");
assert_unsupported(
texts[2],
"text",
"CLIPPED",
"REVIEW-TEXT-RENDER-MODE-CLIP",
);
}

#[test]
fn classify_malformed_text_state_operators_fail_closed() {
let scratch = Scratch::new("review-malformed-text-state");
for (name, content) in [
("missing-rise.pdf", &b"BT /F1 12 Tf Ts (Hi) Tj ET"[..]),
(
"invalid-render-mode.pdf",
&b"BT /F1 12 Tf 1.5 Tr (Hi) Tj ET"[..],
),
(
"out-of-range-render-mode.pdf",
&b"BT /F1 12 Tf 8 Tr (Hi) Tj ET"[..],
),
] {
let path = scratch.file(name);
write_helvetica_page(&path, content);
assert_eq!(
classify_source_content(&path).unwrap_err().code,
"MALFORMED_CONTENT"
);
}
}

#[test]
fn classify_repeated_form_occurrences_have_unique_locators() {
let scratch = Scratch::new("review-repeated-form");
Expand Down
Loading