use std::fmt;
use std::rc::Rc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, thiserror::Error)]
pub enum PdfEditError {
#[error("PDF changed since edit started")]
StaleSource,
#[error("source operand changed")]
SourceDigestMismatch,
#[error("visible text has no unique editable source")]
#[allow(dead_code)] NoUniqueMatch,
#[error("text transform is unsupported")]
UnsupportedTransform,
#[error("font encoding is unsupported")]
UnsupportedEncoding,
#[error("selected font lacks a required glyph")]
MissingGlyph,
#[error("font license forbids embedding")]
FontEmbeddingDenied,
#[error("text exceeds safe bounds")]
TextOverflow,
#[error("PDF content stream is malformed")]
MalformedContent,
#[error("encrypted PDF cannot be edited")]
EncryptedDocument,
#[error("annotations could not be serialized")]
AnnotationSerializationFailed,
#[error("candidate verification failed")]
VerificationFailed,
#[error("target changed before save")]
WriteConflict,
}
impl PdfEditError {
pub fn code(&self) -> &'static str {
match self {
PdfEditError::StaleSource => "stale_source",
PdfEditError::SourceDigestMismatch => "source_digest_mismatch",
PdfEditError::NoUniqueMatch => "no_unique_match",
PdfEditError::UnsupportedTransform => "unsupported_transform",
PdfEditError::UnsupportedEncoding => "unsupported_encoding",
PdfEditError::MissingGlyph => "missing_glyph",
PdfEditError::FontEmbeddingDenied => "font_embedding_denied",
PdfEditError::TextOverflow => "text_overflow",
PdfEditError::MalformedContent => "malformed_content",
PdfEditError::EncryptedDocument => "encrypted_document",
PdfEditError::AnnotationSerializationFailed => "annotation_serialization_failed",
PdfEditError::VerificationFailed => "verification_failed",
PdfEditError::WriteConflict => "write_conflict",
}
}
}
impl Serialize for PdfEditError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("PdfEditError", 2)?;
state.serialize_field("code", self.code())?;
state.serialize_field("message", &self.to_string())?;
state.end()
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PdfPoint {
pub x: f64,
pub y: f64,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PdfRect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl PdfRect {
pub fn contains(&self, other: PdfRect) -> bool {
other.x >= self.x
&& other.y >= self.y
&& other.x + other.width <= self.x + self.width
&& other.y + other.height <= self.y + self.height
}
pub fn intersects(&self, other: PdfRect) -> bool {
self.x < other.x + other.width
&& other.x < self.x + self.width
&& self.y < other.y + other.height
&& other.y < self.y + self.height
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PdfColor {
pub r: f64,
pub g: f64,
pub b: f64,
pub alpha: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PdfTextAppearance {
pub font_id: String,
pub family: String,
pub size_pt: f64,
pub color: PdfColor,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] pub struct PdfDocumentRevision {
pub document_sha256: String,
pub page_count: u32,
pub pages: Vec<PdfPageGraph>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfPageGraph {
pub page: u32,
pub document_sha256: String,
pub media_box: [f64; 4],
pub crop_box: [f64; 4],
pub rotation: i32,
pub islands: Vec<PdfTextIsland>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfTextIsland {
pub id: String,
pub text: String,
pub quads: Vec<[f64; 8]>,
pub baseline: [f64; 4],
pub appearance: PdfTextAppearance,
pub locator: PdfSourceLocator,
pub editable: bool,
pub rejection: Option<PdfEditError>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfSourceLocator {
pub document_sha256: String,
pub page: u32,
pub spans: Vec<PdfSourceSpan>,
pub island_digest: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfSourceSpan {
pub stream_ref: (u32, u16),
pub contents_index: u32,
pub operator_index: u32,
pub operand_index: u32,
pub glyph_start: u32,
pub glyph_end: u32,
pub expected_text: String,
pub expected_digest: String,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Matrix {
pub a: f64,
pub b: f64,
pub c: f64,
pub d: f64,
pub e: f64,
pub f: f64,
}
impl Matrix {
pub const IDENTITY: Matrix = Matrix {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: 0.0,
f: 0.0,
};
pub fn translation(tx: f64, ty: f64) -> Matrix {
Matrix {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: tx,
f: ty,
}
}
pub fn then(self, other: Matrix) -> Matrix {
Matrix {
a: self.a * other.a + self.b * other.c,
b: self.a * other.b + self.b * other.d,
c: self.c * other.a + self.d * other.c,
d: self.c * other.b + self.d * other.d,
e: self.e * other.a + self.f * other.c + other.e,
f: self.e * other.b + self.f * other.d + other.f,
}
}
pub fn transform(self, point: (f64, f64)) -> (f64, f64) {
(
point.0 * self.a + point.1 * self.c + self.e,
point.0 * self.b + point.1 * self.d + self.f,
)
}
pub fn origin(self) -> (f64, f64) {
(self.e, self.f)
}
pub fn translate_text(&mut self, tx: f64, ty: f64) {
*self = Matrix::translation(tx, ty).then(*self);
}
}
#[derive(Clone, Debug)]
pub struct DecodedGlyph {
pub cid: u32,
pub unicode: String,
pub source_range: (u32, u32),
pub bbox: Option<[f64; 4]>,
}
pub trait TextStateFont: fmt::Debug {
fn id(&self) -> &str;
fn decode_show_operand(
&self,
operand: &lopdf::Object,
) -> Result<Vec<DecodedGlyph>, PdfEditError>;
fn advance(&self, cid: u32, font_size: f64) -> f64;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextRenderMode {
Fill,
Stroke,
FillStroke,
Invisible,
FillClip,
StrokeClip,
FillStrokeClip,
Clip,
}
impl TextRenderMode {
pub fn from_operand(value: i64) -> TextRenderMode {
match value {
0 => TextRenderMode::Fill,
1 => TextRenderMode::Stroke,
2 => TextRenderMode::FillStroke,
4 => TextRenderMode::FillClip,
5 => TextRenderMode::StrokeClip,
6 => TextRenderMode::FillStrokeClip,
7 => TextRenderMode::Clip,
_ => TextRenderMode::Invisible,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct PaintState {
pub render_mode: TextRenderMode,
pub fill_color: Option<[f64; 4]>,
pub stroke_color: Option<[f64; 4]>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WritingDirection {
LeftToRight,
#[allow(dead_code)]
RightToLeft,
#[allow(dead_code)]
Vertical,
}
#[derive(Clone, Debug)]
pub struct SourceAddress {
pub stream_ref: (u32, u16),
pub contents_index: u32,
pub operator_index: u32,
pub operand_index: u32,
}
impl SourceAddress {
pub fn with_glyph(&self, glyph_range: (u32, u32)) -> GlyphSourceAddress {
GlyphSourceAddress {
stream_ref: self.stream_ref,
contents_index: self.contents_index,
operator_index: self.operator_index,
operand_index: self.operand_index,
glyph_start: glyph_range.0,
glyph_end: glyph_range.1,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct GlyphSourceAddress {
pub stream_ref: (u32, u16),
pub contents_index: u32,
pub operator_index: u32,
pub operand_index: u32,
pub glyph_start: u32,
pub glyph_end: u32,
}
#[derive(Clone, Debug)]
pub struct PositionedGlyph {
pub page: u32,
pub unicode: String,
#[allow(dead_code)] pub cid: u32,
pub origin: (f64, f64),
pub quad: [f64; 8],
pub source: GlyphSourceAddress,
pub font_id: String,
pub font_size: f64,
pub text_matrix: Matrix,
pub ctm: Matrix,
pub writing_direction: WritingDirection,
pub paint: PaintState,
}
#[derive(Clone, Debug)]
pub struct TextState {
pub ctm: Matrix,
pub text_matrix: Matrix,
pub text_line_matrix: Matrix,
pub font: Option<Rc<dyn TextStateFont>>,
pub font_size: f64,
pub char_spacing: f64,
pub word_spacing: f64,
pub horizontal_scale: f64,
pub leading: f64,
pub text_rise: f64,
pub render_mode: TextRenderMode,
}
impl TextState {
pub fn new() -> TextState {
TextState {
ctm: Matrix::IDENTITY,
text_matrix: Matrix::IDENTITY,
text_line_matrix: Matrix::IDENTITY,
font: None,
font_size: 0.0,
char_spacing: 0.0,
word_spacing: 0.0,
horizontal_scale: 1.0,
leading: 0.0,
text_rise: 0.0,
render_mode: TextRenderMode::Fill,
}
}
pub fn begin_text(&mut self) {
self.text_matrix = Matrix::IDENTITY;
self.text_line_matrix = Matrix::IDENTITY;
}
pub fn set_tm(&mut self, m: Matrix) {
self.text_matrix = m;
self.text_line_matrix = m;
}
pub fn move_line(&mut self, tx: f64, ty: f64) {
self.text_line_matrix = Matrix::translation(tx, ty).then(self.text_line_matrix);
self.text_matrix = self.text_line_matrix;
}
pub fn next_line(&mut self) {
self.move_line(0.0, -self.leading);
}
pub fn origin(&self) -> (f64, f64) {
self.ctm.transform(self.text_matrix.origin())
}
pub fn glyph_quad(
&self,
glyph: &DecodedGlyph,
text_matrix: Matrix,
) -> Result<[f64; 8], PdfEditError> {
let font = self.font.as_ref().ok_or(PdfEditError::MalformedContent)?;
let (x_min, y_min, x_max, y_max) = match glyph.bbox {
Some([x0, y0, x1, y1]) => (
x0 / 1000.0 * self.font_size,
y0 / 1000.0 * self.font_size,
x1 / 1000.0 * self.font_size,
y1 / 1000.0 * self.font_size,
),
None => {
let advance = font.advance(glyph.cid, self.font_size);
(0.0, self.font_size * -0.25, advance, self.font_size * 0.75)
}
};
let corners = [
(x_min, y_min),
(x_max, y_min),
(x_max, y_max),
(x_min, y_max),
];
let mut quad = [0.0; 8];
for (i, (x, y)) in corners.iter().enumerate() {
let scaled = (x * self.horizontal_scale, y + self.text_rise);
let (tx, ty) = text_matrix.transform(scaled);
let (dx, dy) = self.ctm.transform((tx, ty));
quad[i * 2] = dx;
quad[i * 2 + 1] = dy;
}
Ok(quad)
}
}
impl Default for TextState {
fn default() -> Self {
TextState::new()
}
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub struct PaintedBounds {
pub page: u32,
pub bounds: PdfRect,
pub unrelated: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PdfTextEditState {
pub text: String,
pub appearance: PdfTextAppearance,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum PdfOcrBackgroundProof {
NearUniform,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
#[allow(clippy::large_enum_variant)]
pub enum PdfEditCommand {
ReplaceSourceText {
id: String,
locator: PdfSourceLocator,
before: PdfTextEditState,
after: PdfTextEditState,
available_bounds: PdfRect,
},
AddText {
id: String,
page: u32,
anchor: PdfPoint,
text: String,
appearance: PdfTextAppearance,
},
#[serde(rename = "correctOcrText")]
CorrectOcrLine {
id: String,
page: u32,
line_id: String,
rect: PdfRect,
text: String,
appearance: PdfTextAppearance,
cover_color: PdfColor,
background_proof: PdfOcrBackgroundProof,
},
}
pub(crate) fn effective_pdf_edit_commands(commands: &[PdfEditCommand]) -> Vec<&PdfEditCommand> {
use std::collections::HashMap;
let mut last_correction: HashMap<&str, usize> = HashMap::new();
for (index, command) in commands.iter().enumerate() {
if let PdfEditCommand::CorrectOcrLine { line_id, .. } = command {
last_correction.insert(line_id.as_str(), index);
}
}
commands
.iter()
.enumerate()
.filter_map(|(index, command)| match command {
PdfEditCommand::CorrectOcrLine { line_id, .. }
if last_correction.get(line_id.as_str()) != Some(&index) =>
{
None
}
_ => Some(command),
})
.collect()
}
#[derive(Clone, Debug)]
pub struct ComposedCandidate {
pub bytes: Vec<u8>,
pub affected_pages: Vec<u32>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum PdfFontSource {
Bundled,
System,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfFontCatalogEntry {
pub id: String,
pub family: String,
pub postscript_name: Option<String>,
pub styles: Vec<String>,
pub embeddable: bool,
pub subsettable: bool,
pub source: PdfFontSource,
}
#[derive(Clone, Debug)]
pub struct ResolvedPdfFont {
#[allow(dead_code)] pub id: String,
pub family: String,
pub bytes: Vec<u8>,
pub face_index: u32,
#[allow(dead_code)] pub embeddable: bool,
#[allow(dead_code)]
pub subsettable: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
pub struct GlyphId(pub u16);
pub trait PdfFontResolver {
fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError>;
fn glyphs_for_text(
&self,
font: &ResolvedPdfFont,
text: &str,
) -> Result<Vec<GlyphId>, PdfEditError>;
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest: [u8; 32] = Sha256::digest(bytes).into();
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
pub fn document_sha256(source_bytes: &[u8]) -> String {
sha256_hex(source_bytes)
}
pub fn expected_text_digest(expected_text: &str) -> String {
sha256_hex(expected_text.as_bytes())
}
#[allow(clippy::too_many_arguments)]
pub fn compute_island_digest(
spans: &[PdfSourceSpan],
decoded_text: &str,
font_id: &str,
text_matrix: &Matrix,
ctm: &Matrix,
paint: &PaintState,
quads: &[[f64; 8]],
) -> String {
let mut buf = String::new();
for span in spans {
buf.push_str(&format!(
"span:{}.{}/{}/{}/{}-{}|",
span.stream_ref.0,
span.stream_ref.1,
span.contents_index,
span.operator_index,
span.operand_index,
span.glyph_start
));
buf.push_str(&format!("{}\n", span.glyph_end));
}
buf.push_str("text:");
buf.push_str(decoded_text);
buf.push_str("\nfont:");
buf.push_str(font_id);
buf.push_str(&format!(
"\ntm:{},{},{},{},{},{}",
text_matrix.a, text_matrix.b, text_matrix.c, text_matrix.d, text_matrix.e, text_matrix.f
));
buf.push_str(&format!(
"\nctm:{},{},{},{},{},{}",
ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f
));
buf.push_str(&format!(
"\npaint:{:?}/{:?}/{:?}",
paint.render_mode, paint.fill_color, paint.stroke_color
));
for quad in quads {
buf.push_str("\nquad:");
for value in quad {
buf.push_str(&format!("{value},"));
}
}
sha256_hex(buf.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_hex_of_empty_input_is_64_hex_chars_and_stable() {
let a = sha256_hex(b"");
let b = sha256_hex(b"");
assert_eq!(a, b);
assert_eq!(a.len(), 64);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn sha256_hex_is_deterministic_and_64_hex_chars() {
let a = sha256_hex(b"viewright pdf editor");
let b = sha256_hex(b"viewright pdf editor");
assert_eq!(a, b);
assert_eq!(a.len(), 64);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn sha256_hex_differs_for_different_input() {
assert_ne!(
sha256_hex(b"CAR YOU LIST."),
sha256_hex(b"CAR YOU LIST, YOU WIN.")
);
}
#[test]
fn document_sha256_matches_sha256_hex() {
let bytes = b"%PDF-1.7 fixture bytes";
assert_eq!(document_sha256(bytes), sha256_hex(bytes));
}
#[test]
fn expected_text_digest_is_stable_and_distinct() {
let a = expected_text_digest("CAR YOU LIST.");
let b = expected_text_digest("CAR YOU LIST.");
let c = expected_text_digest("CAR YOU LIST, YOU WIN.");
assert_eq!(a, b);
assert_ne!(a, c);
}
fn sample_span(operator_index: u32, glyph_start: u32, glyph_end: u32) -> PdfSourceSpan {
PdfSourceSpan {
stream_ref: (7, 0),
contents_index: 0,
operator_index,
operand_index: 0,
glyph_start,
glyph_end,
expected_text: "CAR".to_string(),
expected_digest: expected_text_digest("CAR"),
}
}
#[test]
fn island_digest_is_stable_for_identical_inputs() {
let spans = vec![sample_span(0, 0, 3)];
let paint = PaintState {
render_mode: TextRenderMode::Fill,
fill_color: Some([0.0, 0.0, 0.0, 1.0]),
stroke_color: None,
};
let tm = Matrix::IDENTITY;
let ctm = Matrix::IDENTITY;
let quads = vec![[0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0]];
let a = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
let b = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
assert_eq!(a, b);
assert_eq!(a.len(), 64);
}
#[test]
fn island_digest_distinguishes_repeated_text_by_span_position() {
let paint = PaintState {
render_mode: TextRenderMode::Fill,
fill_color: None,
stroke_color: None,
};
let tm = Matrix::IDENTITY;
let ctm = Matrix::IDENTITY;
let quads = vec![[0.0; 8]];
let first = compute_island_digest(
&[sample_span(0, 0, 3)],
"CAR",
"F1",
&tm,
&ctm,
&paint,
&quads,
);
let second = compute_island_digest(
&[sample_span(5, 0, 3)],
"CAR",
"F1",
&tm,
&ctm,
&paint,
&quads,
);
assert_ne!(first, second);
}
#[test]
fn island_digest_distinguishes_by_matrix() {
let spans = vec![sample_span(0, 0, 3)];
let paint = PaintState {
render_mode: TextRenderMode::Fill,
fill_color: None,
stroke_color: None,
};
let ctm = Matrix::IDENTITY;
let quads = vec![[0.0; 8]];
let a = compute_island_digest(&spans, "CAR", "F1", &Matrix::IDENTITY, &ctm, &paint, &quads);
let b = compute_island_digest(
&spans,
"CAR",
"F1",
&Matrix::translation(12.0, 0.0),
&ctm,
&paint,
&quads,
);
assert_ne!(a, b);
}
#[test]
fn matrix_identity_transform_is_noop() {
let (x, y) = Matrix::IDENTITY.transform((3.0, 4.0));
assert_eq!((x, y), (3.0, 4.0));
}
#[test]
fn matrix_translation_then_composes_in_order() {
let m = Matrix::translation(10.0, 0.0).then(Matrix::translation(0.0, 5.0));
assert_eq!(m.transform((0.0, 0.0)), (10.0, 5.0));
}
#[test]
fn text_state_move_line_updates_origin() {
let mut state = TextState::new();
state.begin_text();
state.move_line(12.0, -20.0);
assert_eq!(state.text_matrix.origin(), (12.0, -20.0));
assert_eq!(state.origin(), (12.0, -20.0));
}
#[test]
fn text_state_next_line_uses_leading() {
let mut state = TextState::new();
state.leading = 14.0;
state.next_line();
assert_eq!(state.text_matrix.origin(), (0.0, -14.0));
}
#[test]
fn pdf_rect_contains_and_intersects() {
let outer = PdfRect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 50.0,
};
let inner = PdfRect {
x: 10.0,
y: 10.0,
width: 20.0,
height: 10.0,
};
let outside = PdfRect {
x: 200.0,
y: 200.0,
width: 10.0,
height: 10.0,
};
assert!(outer.contains(inner));
assert!(!outer.contains(outside));
assert!(outer.intersects(inner));
assert!(!outer.intersects(outside));
}
#[test]
fn text_render_mode_from_operand_covers_spec_range() {
assert_eq!(TextRenderMode::from_operand(0), TextRenderMode::Fill);
assert_eq!(TextRenderMode::from_operand(1), TextRenderMode::Stroke);
assert_eq!(TextRenderMode::from_operand(2), TextRenderMode::FillStroke);
assert_eq!(TextRenderMode::from_operand(7), TextRenderMode::Clip);
assert_eq!(TextRenderMode::from_operand(99), TextRenderMode::Invisible);
}
#[test]
fn pdf_source_locator_round_trips_through_json() {
let locator = PdfSourceLocator {
document_sha256: document_sha256(b"fixture"),
page: 1,
spans: vec![sample_span(0, 0, 3)],
island_digest: sha256_hex(b"island"),
};
let json = serde_json::to_string(&locator).expect("serialize");
assert!(json.contains("\"documentSha256\""));
assert!(json.contains("\"islandDigest\""));
let round_tripped: PdfSourceLocator = serde_json::from_str(&json).expect("deserialize");
assert_eq!(round_tripped.page, locator.page);
assert_eq!(round_tripped.spans.len(), locator.spans.len());
assert_eq!(round_tripped.island_digest, locator.island_digest);
}
fn sample_appearance() -> PdfTextAppearance {
PdfTextAppearance {
font_id: "source-serif-4".to_string(),
family: "Source Serif 4".to_string(),
size_pt: 34.0,
color: PdfColor {
r: 0.0,
g: 0.0,
b: 0.0,
alpha: 1.0,
},
}
}
#[test]
fn pdf_edit_command_replace_source_text_round_trips() {
let command = PdfEditCommand::ReplaceSourceText {
id: "edit-1".to_string(),
locator: PdfSourceLocator {
document_sha256: document_sha256(b"fixture"),
page: 1,
spans: vec![sample_span(0, 0, 3)],
island_digest: sha256_hex(b"island"),
},
before: PdfTextEditState {
text: "CAR YOU LIST.".to_string(),
appearance: sample_appearance(),
},
after: PdfTextEditState {
text: "CAR YOU LIST, YOU WIN.".to_string(),
appearance: sample_appearance(),
},
available_bounds: PdfRect {
x: 0.0,
y: 0.0,
width: 200.0,
height: 40.0,
},
};
let json = serde_json::to_string(&command).expect("serialize");
assert!(json.contains("\"kind\":\"replaceSourceText\""));
assert!(json.contains("\"availableBounds\""));
let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
match round_tripped {
PdfEditCommand::ReplaceSourceText { id, after, .. } => {
assert_eq!(id, "edit-1");
assert_eq!(after.text, "CAR YOU LIST, YOU WIN.");
}
PdfEditCommand::AddText { .. } => panic!("expected ReplaceSourceText"),
PdfEditCommand::CorrectOcrLine { .. } => panic!("expected ReplaceSourceText"),
}
}
#[test]
fn pdf_edit_command_add_text_round_trips() {
let command = PdfEditCommand::AddText {
id: "add-1".to_string(),
page: 2,
anchor: PdfPoint { x: 72.0, y: 144.0 },
text: "New line".to_string(),
appearance: sample_appearance(),
};
let json = serde_json::to_string(&command).expect("serialize");
assert!(json.contains("\"kind\":\"addText\""));
let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
match round_tripped {
PdfEditCommand::AddText { id, page, text, .. } => {
assert_eq!(id, "add-1");
assert_eq!(page, 2);
assert_eq!(text, "New line");
}
PdfEditCommand::ReplaceSourceText { .. } => panic!("expected AddText"),
PdfEditCommand::CorrectOcrLine { .. } => panic!("expected AddText"),
}
}
#[test]
fn pdf_edit_command_correct_ocr_text_round_trips() {
let command = PdfEditCommand::CorrectOcrLine {
id: "ocr-1".to_string(),
page: 1,
line_id: "line-7".to_string(),
rect: PdfRect {
x: 72.0,
y: 700.0,
width: 120.0,
height: 18.0,
},
text: "Corrected".to_string(),
appearance: sample_appearance(),
cover_color: PdfColor {
r: 1.0,
g: 1.0,
b: 1.0,
alpha: 1.0,
},
background_proof: PdfOcrBackgroundProof::NearUniform,
};
let json = serde_json::to_string(&command).expect("serialize");
assert!(json.contains("\"kind\":\"correctOcrText\""));
assert!(json.contains("\"lineId\":\"line-7\""));
assert!(json.contains("\"backgroundProof\":\"nearUniform\""));
let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
assert!(matches!(
round_tripped,
PdfEditCommand::CorrectOcrLine {
background_proof: PdfOcrBackgroundProof::NearUniform,
..
}
));
let invalid = json.replace("nearUniform", "gradient");
assert!(serde_json::from_str::<PdfEditCommand>(&invalid).is_err());
}
#[test]
fn effective_commands_last_ocr_correction_wins() {
let appearance = sample_appearance();
let correction = |id: &str, text: &str| PdfEditCommand::CorrectOcrLine {
id: id.to_string(),
page: 1,
line_id: "line-1".to_string(),
rect: PdfRect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 20.0,
},
text: text.to_string(),
appearance: appearance.clone(),
cover_color: PdfColor {
r: 1.0,
g: 1.0,
b: 1.0,
alpha: 1.0,
},
background_proof: PdfOcrBackgroundProof::NearUniform,
};
let commands = vec![correction("old", "old"), correction("new", "new")];
let active = effective_pdf_edit_commands(&commands);
assert_eq!(active.len(), 1);
assert!(matches!(active[0], PdfEditCommand::CorrectOcrLine { text, .. } if text == "new"));
}
#[test]
fn pdf_font_source_round_trips() {
for (source, wire) in [
(PdfFontSource::Bundled, "\"bundled\""),
(PdfFontSource::System, "\"system\""),
] {
let json = serde_json::to_string(&source).expect("serialize");
assert_eq!(json, wire, "frontend expects a string source discriminator");
let round_tripped: PdfFontSource = serde_json::from_str(&json).expect("deserialize");
assert_eq!(round_tripped, source);
}
}
#[test]
fn pdf_font_catalog_entry_serializes_camel_case() {
let entry = PdfFontCatalogEntry {
id: "source-serif-4".to_string(),
family: "Source Serif 4".to_string(),
postscript_name: Some("SourceSerif4-Regular".to_string()),
styles: vec!["Regular".to_string(), "Bold".to_string()],
embeddable: true,
subsettable: true,
source: PdfFontSource::Bundled,
};
let json = serde_json::to_string(&entry).expect("serialize");
assert!(json.contains("\"postscriptName\""));
assert!(json.contains("\"subsettable\""));
assert_eq!(
serde_json::from_str::<serde_json::Value>(&json).unwrap()["source"],
"bundled"
);
}
#[test]
fn pdf_edit_error_serializes_as_code_message_envelope() {
let json = serde_json::to_string(&PdfEditError::StaleSource).expect("serialize");
assert_eq!(
json,
r#"{"code":"stale_source","message":"PDF changed since edit started"}"#
);
assert_eq!(
PdfEditError::TextOverflow.to_string(),
"text exceeds safe bounds"
);
assert_eq!(PdfEditError::TextOverflow.code(), "text_overflow");
}
#[derive(Debug)]
struct FixedAdvanceFont {
advance: f64,
}
impl TextStateFont for FixedAdvanceFont {
fn id(&self) -> &str {
"fixed"
}
fn decode_show_operand(
&self,
_operand: &lopdf::Object,
) -> Result<Vec<DecodedGlyph>, PdfEditError> {
Ok(Vec::new())
}
fn advance(&self, _cid: u32, _font_size: f64) -> f64 {
self.advance
}
}
#[test]
fn glyph_quad_without_bbox_uses_advance_fallback() {
let mut state = TextState::new();
state.font = Some(Rc::new(FixedAdvanceFont { advance: 10.0 }));
state.font_size = 12.0;
let glyph = DecodedGlyph {
cid: 1,
unicode: "A".to_string(),
source_range: (0, 1),
bbox: None,
};
let quad = state
.glyph_quad(&glyph, Matrix::IDENTITY)
.expect("font is set");
assert_eq!((quad[0], quad[1]), (0.0, state.font_size * -0.25));
assert_eq!((quad[2], quad[3]), (10.0, state.font_size * -0.25));
}
#[test]
fn glyph_quad_without_font_is_malformed_content() {
let state = TextState::new();
let glyph = DecodedGlyph {
cid: 1,
unicode: "A".to_string(),
source_range: (0, 1),
bbox: None,
};
let err = state
.glyph_quad(&glyph, Matrix::IDENTITY)
.expect_err("no font set");
assert!(matches!(err, PdfEditError::MalformedContent));
}
#[test]
fn source_address_with_glyph_narrows_correctly() {
let address = SourceAddress {
stream_ref: (7, 0),
contents_index: 1,
operator_index: 2,
operand_index: 0,
};
let glyph_address = address.with_glyph((3, 6));
assert_eq!(glyph_address.stream_ref, (7, 0));
assert_eq!(glyph_address.contents_index, 1);
assert_eq!(glyph_address.operator_index, 2);
assert_eq!(glyph_address.glyph_start, 3);
assert_eq!(glyph_address.glyph_end, 6);
}
}