M5: text boxes and redaction marks without an appearance draw their style and outline, ported onto main

A single squashed port of the branch's five commits onto current main, which
split annot's lib.rs into modules (create.rs, modify.rs, info.rs, draw.rs) and
appearance::build into per-subtype drawing functions. The behaviour and tests
are the branch's (ISO 32000-2 Tables 166, 177, 195):

- text boxes without /AP draw their default 1-pt border, their /DA face when it
  is a standard text face, and /DS font, size and colour (text_style.rs);
- unapplied redaction marks are outlined in an array /OC colour, with or
  without /QuadPoints, and keep /C otherwise;
- a text box's colour or size change keeps the face it is drawn in
  (restyle_text), and edited text boxes re-fit in their drawn face and size.

On main, the branch's set_style test calls use StyleChange, and the parity
entry keeps main's evidence paths (render raster/tests.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-10-30 23:56:30 +08:00
1 parent 1d1cbc4ec9
commit ff886283f8
10 files changed
+870 -40

No files matched your search

+49 -15
View File
@@ -6,9 +6,10 @@
//! It returns `None` for anything it cannot draw faithfully (cloudy borders, unknown line
//! endings, indirect geometry), so callers never replace an appearance with a worse one.
//!
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard
//! Helvetica font with WinAnsi encoding; line breaking uses [`text_width`], an approximation of
//! Helvetica's proportions by character class (no font program or metrics file is bundled).
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use a standard text
//! font with WinAnsi encoding: Helvetica, or the face their `/DA` or `/DS` asks for
//! ([`crate::text_style`]); lines break at that face's standard-14 widths (no font program is
//! bundled).
use pdfcraft_cos::{Dict, Object, PdfString, Stream};
use pdfcraft_fonts::{first_undrawable, literal, win_ansi};
@@ -326,13 +327,19 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
fn helvetica_resource() -> Object {
font_resource(pdfcraft_fonts::Std14::Helvetica)
}
/// `/Font` resources naming one standard face with WinAnsiEncoding, under the resource name
/// [`crate::text_style::resource_name`] gives it (`/Helv` for Helvetica).
fn font_resource(face: pdfcraft_fonts::Std14) -> Object {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"BaseFont".to_vec(), Object::name(face.base_font()));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
fonts.set(crate::text_style::resource_name(face).as_bytes().to_vec(), Object::Dict(font));
Object::Dict(fonts)
}
@@ -406,13 +413,19 @@ impl AppearanceDrawing {
/// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and
/// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`).
pub fn build(d: &Dict) -> Option<Stream> {
build_in(d, None)
}
/// [`build`], with the face a text box's `/DA` font names in the form's `/DR`
/// ([`crate::text_style::dr_face`]).
pub(crate) fn build_in(d: &Dict, dr_face: Option<pdfcraft_fonts::Std14>) -> Option<Stream> {
let context = appearance_context(d)?;
let drawing = AppearanceDrawing::new(&context);
match context.subtype {
b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing),
b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing),
b"Stamp" => return draw_stamp(&context, drawing),
b"FreeText" => return draw_free_text(&context, drawing),
b"FreeText" => return draw_free_text(&context, drawing, dr_face),
_ => {}
}
let mut drawing = drawing;
@@ -532,8 +545,27 @@ fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawi
let stroke = context.stroke;
let c = &mut drawing.content;
// While marked: the outline of each area (the fill comes when applied).
let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
let col = stroke.unwrap_or([0.89, 0.13, 0.13]);
// ISO 32000-2 gives a mark no outline colour (§12.5.6.23, Table 195). An array `/OC` of
// three numbers is the one some writers give it, and is drawn (the observation is in
// `parity/acrobat-features.toml`); `/OC` as ISO 32000-2 defines it (Table 166) is an
// optional-content dictionary, which this leaves alone.
let outline = nums(d, b"OC").and_then(|c| match c.as_slice() {
[r, g, b] if c.iter().all(|x| x.is_finite()) => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]),
_ => None,
});
// Quadrilaterals are outlined in the `/OC` colour, else in `/C` as they always were.
let (q, col) = match d.get(b"QuadPoints") {
Some(_) => (nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?, outline.or(stroke)),
// Without `/QuadPoints`, `/Rect` is the area (Table 195): outlined just inside its
// edges when the mark names an outline colour, else not drawn while marked.
None => {
let outline = outline?;
let rect = context.rect;
let [x0, y0, x1, y1] = [rect[0] + 0.5, rect[1] + 0.5, rect[2] - 0.5, rect[3] - 0.5];
(vec![x0, y1, x1, y1, x0, y0, x1, y0], Some(outline))
}
};
let col = col.unwrap_or([0.89, 0.13, 0.13]);
c.push_str(&format!("{}1 w\n", rg_stroke(col)));
for quad in q.as_chunks::<8>().0.iter() {
c.push_str(&format!(
@@ -889,16 +921,17 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
Some(form(rect, c.as_bytes(), res))
}
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option<Stream> {
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing, dr_face: Option<pdfcraft_fonts::Std14>) -> Option<Stream> {
let d = context.dict;
let rect = context.rect;
let stroke = context.stroke;
let mut c = std::mem::take(&mut drawing.content);
let mut res = std::mem::take(&mut drawing.resources);
let _tail = std::mem::take(&mut drawing.tail);
let (text_color, size) = parse_da(d);
let crate::text_style::TextStyle { face, size, color: text_color } = crate::text_style::text_style(d, dr_face);
let bg = stroke;
let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
// Without `/BS` or `/Border`, the `/Border` default [0 0 1]: a 1-pt border (Table 166).
let bw = border_width(d);
// A callout: the leader line (arrowhead at its first point), and the text box inside
// `/Rect` by `/RD`.
// Text added to a turned page is drawn upright in its displayed box, which the form
@@ -965,21 +998,22 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0);
let q = d.int(b"Q").unwrap_or(0);
c.push_str(&format!(
"{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}",
"{} {} {} {} re W n\nBT\n/{} {} Tf\n{}",
n(rect[0]),
n(rect[1]),
n(rect[2] - rect[0]),
n(rect[3] - rect[1]),
crate::text_style::resource_name(face),
n(size),
rg(text_color)
));
let mut out = c.into_bytes();
let mut y = rect[3] - pad - size * 0.9;
for line in wrap(&text, size, width) {
for line in pdfcraft_fonts::wrap_with(&text, size, width, |s, size| face.text_width(s, size)) {
if y < rect[1] - size {
break;
}
let lw = text_width(&line, size);
let lw = face.text_width(&line, size);
let x = match q {
1 => rect[0] + pad + (width - lw) / 2.0,
2 => rect[2] - pad - lw,
@@ -991,7 +1025,7 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
y -= size * 1.2;
}
out.extend_from_slice(b"ET\n");
res.set(b"Font".to_vec(), helvetica_resource());
res.set(b"Font".to_vec(), font_resource(face));
let mut stream = form(full, &out, res);
if let Some(m) = matrix {
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
+2 -1
View File
@@ -1,6 +1,7 @@
use super::create::{finite, num_array};
use super::lookup::{annot_dict, annots, page_refs};
use super::modify::refuse_foreign_text;
use super::text_style;
use super::types::NOTE_SIZE;
use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance};
@@ -40,7 +41,7 @@ pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), Annot
/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`].
/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it.
fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
let mut stream = appearance::build(d)?;
let mut stream = appearance::build_in(d, text_style::dr_face(doc, d))?;
// Picture-stamp restyling must retain the placement's page-axis correction.
if d.get(b"PCPicture").is_some() {
let matrix = d.get(b"AP").and_then(|ap| {
+9 -3
View File
@@ -2,6 +2,7 @@ use super::create::finite;
use super::draw::set_appearance;
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
use super::modify::{fillable, touch, unlocked};
use super::text_style;
use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
@@ -223,10 +224,15 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
let d = obj.as_dict()?;
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
let c: Vec<f64> = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default();
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) };
// A text box reports the colour and size it is drawn in (`/DA`, or `/DS` where it sets one).
let style = (subtype == "FreeText").then(|| text_style::text_style(d, None));
let color = match &style {
Some(s) => Some(s.color),
None => (c.len() == 3).then(|| [c[0], c[1], c[2]]),
};
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
if subtype == "FreeText" {
if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
appearance::border_width(d)
} else {
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)
}
@@ -243,7 +249,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
fillable: fillable(&subtype),
fill: fill_of(d),
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
font_size: style.map(|s| s.size),
subtype,
endings: line_endings_of(d),
})
+1
View File
@@ -46,6 +46,7 @@ pub use types::{
pub mod appearance;
pub mod links;
pub mod text_style;
#[cfg(test)]
mod tests;
+42 -17
View File
@@ -4,11 +4,13 @@ use super::create::{
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
use super::info::{line_endings_of, text_value};
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
use super::text_style::{self, TextStyle};
use super::types::{
FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style,
StyleChange, TextReplacement,
};
use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance};
use pdfcraft_fonts::Std14;
/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would
/// replace those characters with `?`, so the edit is refused before the dictionary changes.
@@ -34,11 +36,28 @@ pub(crate) fn n(v: f64) -> String {
// ── edits ───────────────────────────────────────────────────────────────────────────────────
/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its
/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing.
/// [`rich_text_in`] for a text box whose `/DA` was just written (in Helvetica, as PdfCraft writes it).
pub(crate) fn rich_text(d: &mut Dict) {
let (color, size) = appearance::parse_da(d);
rich_text_in(d, TextStyle { face: Std14::Helvetica, size, color });
}
/// Give a text box the text `style`: `/DA` keeps the font resource it names (`/Helv` when it names
/// none) with the new colour and size, and `/DS` and `/RC` carry the face, so the text keeps the
/// face it is drawn in (ISO 32000-2 Table 177).
fn restyle_text(d: &mut Dict, style: TextStyle) {
let font = text_style::da_font_name(d).unwrap_or_else(|| "Helv".into());
let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0));
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{font} {} Tf", n(r), n(g), n(b), n(style.size)).into_bytes()));
rich_text_in(d, TextStyle { color: [r, g, b], ..style });
}
/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its
/// plain `/Contents`, its text `style` and `/Q`, so viewers that edit rich text show the same thing.
fn rich_text_in(d: &mut Dict, style: TextStyle) {
let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default();
let (col, size) = appearance::parse_da(d);
let TextStyle { face, size, color: col } = style;
let (family, weight, slant) = text_style::css(face);
let hex = format!(
"#{:02X}{:02X}{:02X}",
(col[0].clamp(0.0, 1.0) * 255.0).round() as u8,
@@ -53,13 +72,15 @@ pub(crate) fn rich_text(d: &mut Dict) {
let esc = |s: &str| s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;");
let body: String = text.split('\n').map(|line| format!("<p dir=\"ltr\">{}</p>", esc(line.trim_end_matches('\r')))).collect();
let style = format!(
"font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal",
"font-size:{}pt;text-align:{align};color:{hex};font-weight:{weight};font-style:{slant};font-family:{family};font-stretch:normal",
n(size)
);
let rc = format!(
"<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" xfa:spec=\"2.0.2\" style=\"{style}\">{body}</body>"
);
d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size))));
// "font: Helvetica 12pt" for PdfCraft's own boxes; other faces add their slant and weight.
let keywords = format!("{}{}", if slant == "normal" { "" } else { "italic " }, if weight == "normal" { "" } else { "bold " });
d.set(b"DS".to_vec(), PdfString::text(&format!("font: {keywords}{family} {}pt; text-align:{align}; color:{hex}", n(size))));
d.set(b"RC".to_vec(), PdfString::text(&rc));
}
@@ -129,12 +150,15 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int
/// new text: the wrap width and top edge stay, the height fits the wrapped lines.
pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText");
let before = annot_dict(doc, r);
let free_text = before.name(b"Subtype") == Some(b"FreeText");
// The text keeps the look it is drawn with (`/DA`, or `/DS` where it sets one).
let style = text_style::text_style(&before, text_style::dr_face(doc, &before));
doc.update_dict(r, |d| {
d.set(b"Contents".to_vec(), PdfString::text(text));
// The rich-text version follows the new plain text (text boxes), or would contradict it.
if free_text {
rich_text(d);
rich_text_in(d, style);
} else {
d.remove(b"RC");
}
@@ -159,7 +183,9 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
let d = annot_dict(doc, r);
let nums = |key: &[u8]| -> Option<Vec<f64>> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() };
let (_, size) = appearance::parse_da(&d);
// Measured in the face and size the text is drawn in (`/DA`, or `/DS` where it sets one).
let TextStyle { face, size, .. } = text_style::text_style(&d, text_style::dr_face(doc, &d));
let measure = |s: &str, size: f64| face.text_width(s, size);
let pad = 2.0 + border_width_of(&d);
let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?;
let rect = [rect[0], rect[1], rect[2], rect[3]];
@@ -176,10 +202,10 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
w
} else {
// No usable old width: fall back to the creation-time width (at most 300 pt).
let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
let longest = text.lines().map(|l| measure(l, size)).fold(0.0, f64::max);
(longest + 2.0 * pad + 4.0).clamp(40.0, 300.0)
};
let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64;
let lines = pdfcraft_fonts::wrap_with(text, size, (w - 2.0 * pad).max(1.0), measure).len().max(1) as f64;
Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top])
}
@@ -522,14 +548,13 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
}
refuse_foreign_text(doc, &d)?;
let free_text = subtype == "FreeText";
// The face and size the text is drawn in, which the new colour keeps.
let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d));
let before = d;
doc.update_dict(r, |d| {
if let Some(c) = color {
if free_text {
let (_, size) = appearance::parse_da(d);
let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0));
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()));
rich_text(d);
restyle_text(d, TextStyle { color: c, ..drawn });
} else {
d.set(b"C".to_vec(), rgb(c));
}
@@ -577,11 +602,11 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m
return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into()));
}
refuse_foreign_text(doc, &d)?;
// The face and colour the text is drawn in, which the new size keeps.
let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d));
let before = d;
doc.update_dict(r, |d| {
let (col, _) = appearance::parse_da(d);
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes()));
rich_text(d);
restyle_text(d, TextStyle { size, ..drawn });
touch(d, meta);
})?;
let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default();
+306 -1
View File
@@ -1575,7 +1575,8 @@ fn free_text_properties_show_legacy_or_absent_border_width() {
d.remove(b"Border");
})
.unwrap();
assert_eq!(props(&doc, 0, index).unwrap().width, Some(0.0));
// Neither /BS nor /Border: the /Border default [0 0 1] (ISO 32000-2 Table 166), as drawn.
assert_eq!(props(&doc, 0, index).unwrap().width, Some(1.0));
doc.update_dict(reference, |d| {
d.set(b"Border".to_vec(), Object::Array([0.0, 0.0, 4.0].into_iter().map(Object::Real).collect()));
})
@@ -2166,3 +2167,307 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() {
assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256");
assert!(!long.contains(&"w".repeat(257)));
}
/// PdfCraft's own text boxes, typewriter text and redaction marks write `/BS` (width 0 for text
/// boxes and typewriter text), `/DA /Helv` and a `/DS` that agrees with it, and quadrilaterals:
/// the default border, `/DA` fonts, `/DS` and the `/Rect` outline leave them exactly as they were.
#[test]
fn pdfcraft_text_boxes_and_redaction_marks_look_as_before() {
let mut doc = fixture();
let mut tb = new(0, Shape::TextBox { rect: [100.0, 100.0, 220.0, 160.0], font_size: 12.5 });
tb.style = Style { color: [0.1, 0.2, 0.3], fill: Some([1.0, 1.0, 0.8]), ..Style::default_for(&tb.shape) };
tb.contents = "PdfCraft text box that wraps onto a second line".into();
let t = add_annotation(&mut doc, &tb, &meta("t")).unwrap();
let mut tw = new(0, Shape::Typewriter { rect: [100.0, 300.0, 300.0, 320.0], font_size: 10.0 });
tw.style = Style::default_for(&tw.shape);
let w = add_annotation(&mut doc, &tw, &meta("w")).unwrap();
let quads = vec![[100.0, 520.0, 200.0, 520.0, 100.0, 500.0, 200.0, 500.0]];
let mut rd = new(0, Shape::Redact { quads, overlay: String::new(), look: OverlayLook::default() });
rd.style = Style::default_for(&rd.shape);
let r = add_annotation(&mut doc, &rd, &meta("r")).unwrap();
let all = list(&doc, 0);
assert_eq!(
ap_content(&doc, &all[t]),
"1 1 0.8 rg\n100 100 120 60 re f\n100 100 120 60 re W n\nBT\n/Helv 12.5 Tf\n0.1 0.2 0.3 rg\n1 0 0 1 102 146.75 Tm (PdfCraft text box) Tj\n1 0 0 1 102 131.75 Tm (that wraps onto a) Tj\n1 0 0 1 102 116.75 Tm (second line) Tj\nET\n"
);
assert_eq!(text(&all[t], b"DS"), "font: Helvetica 12.5pt; text-align:left; color:#1A334D");
assert_eq!(ap_content(&doc, &all[w]), "100 300 200 20 re W n\nBT\n/Helv 10 Tf\n0 0 0 rg\n1 0 0 1 102 309 Tm (hello) Tj\nET\n");
assert_eq!(ap_content(&doc, &all[r]), "0.89 0.13 0.13 RG\n1 w\n100 520 m 200 520 l 200 500 l 100 500 l h S\n");
// New text keeps the box's look: Helvetica, /DA's exact colour (not /DS's 8-bit one).
set_contents(&mut doc, 0, t, "Edited", &meta("")).unwrap();
let d = &list(&doc, 0)[t];
assert_eq!(text(d, b"DS"), "font: Helvetica 12.5pt; text-align:left; color:#1A334D");
let ap = ap_content(&doc, d);
assert!(ap.contains("/Helv 12.5 Tf\n0.1 0.2 0.3 rg\n") && !ap.contains(" re S"), "{ap}");
}
/// The display appearance of the annotation dictionary `annot` on a page of a document whose
/// form `/DR` maps `Helv`, `Cour`, `TiRo`, `F1` (Courier) and `F2` (a font that isn't standard),
/// and the font resources it draws with.
fn display_of(annot: &str) -> (String, Dict) {
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /DR << /Font << /Helv 5 0 R /Cour 6 0 R /TiRo 7 0 R /F1 6 0 R /F2 9 0 R >> >> >> >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [8 0 R] >>",
b"<< /Length 0 >>\nstream\n\nendstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>",
annot.as_bytes(),
b"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+HouseGothic >>",
]);
let shown = with_missing_appearances(&doc).expect("drawn for display");
let d = list(&shown, 0).remove(0);
let n = d.get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")).expect("/AP /N");
let Object::Stream(s) = &*shown.get(n) else { panic!("not a stream") };
let fonts = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).cloned();
(String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), fonts.unwrap_or_default())
}
fn base_fonts(fonts: &Dict) -> Vec<String> {
fonts.iter().filter_map(|(_, f)| f.as_dict()?.name(b"BaseFont").map(|b| String::from_utf8_lossy(b).into_owned())).collect()
}
/// A text box without `/BS` or `/Border` has the `/Border` default [0 0 1] (ISO 32000-2
/// Table 166): a 1-pt border in the text colour. `/BS /W 0` or `/Border [0 0 0]` turn it off.
#[test]
fn free_text_without_a_border_entry_has_the_default_one() {
let (ap, _) = display_of("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 0 1 rg /Helv 12 Tf) /Contents (Hi) >>");
assert!(ap.contains("0 0 1 RG\n1 w\n45.5 630.5 119 69 re S\n"), "{ap}");
assert!(ap.contains("1 0 0 1 48 "), "text inset by the border: {ap}");
for off in ["/BS << /W 0 >>", "/Border [0 0 0]"] {
let (ap, _) = display_of(&format!("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) {off} /Contents (Hi) >>"));
assert!(!ap.contains(" re S"), "{off}: {ap}");
}
}
/// `/DA` names its font in the form's `/DR` (§12.7.4.3): a standard face is drawn in that face,
/// with its widths for wrapping; any other font stays Helvetica.
#[test]
fn free_text_da_fonts_draw_in_their_standard_face() {
use pdfcraft_fonts::Std14;
let width = 120.0 - 2.0 * 2.0;
let words = "iiiiiiiiii iiiiiiiiii";
let lines = |face: Std14| pdfcraft_fonts::wrap_with(words, 12.0, width, |s, z| face.text_width(s, z)).len();
assert_eq!((lines(Std14::Helvetica), lines(Std14::Courier)), (1, 2), "the case tells the faces apart");
for (da, base, key, n) in [
("0 g /Cour 12 Tf", "Courier", "/Cour 12 Tf", 2),
("1 0 0 rg /TiRo 12 Tf", "Times-Roman", "/TiRo 12 Tf", 1),
("0 g /F1 12 Tf", "Courier", "/Cour 12 Tf", 2),
("0 g /F2 12 Tf", "Helvetica", "/Helv 12 Tf", 1),
("0 g /Nowhere 12 Tf", "Helvetica", "/Helv 12 Tf", 1),
("0 g /CoBo 12 Tf", "Courier-Bold", "/CoBo 12 Tf", 2),
] {
let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA ({da}) /BS << /W 0 >> /Contents ({words}) >>");
let (ap, fonts) = display_of(&annot);
assert_eq!(base_fonts(&fonts), [base], "{da}");
assert!(ap.contains(key), "{da}: {ap}");
assert_eq!(ap.matches(" Tj").count(), n, "{da}: wrapped at {base}'s widths: {ap}");
}
}
/// `/DS` (Table 177) sets the face, size and colour over `/DA`'s; a malformed one is ignored.
#[test]
fn free_text_ds_style_is_drawn() {
let annot =
"<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) /DS (font: Times 14pt; color: #008000) /Contents (Hi) >>";
let (ap, fonts) = display_of(annot);
assert_eq!(base_fonts(&fonts), ["Times-Roman"]);
assert!(ap.contains("/TiRo 14 Tf\n0 0.502 0 rg\n"), "{ap}");
let junk = format!("font: NaNpt; font-size: inf; color: rgb(1e999, x); {}", "\u{FFFD}".repeat(20_000));
let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 0 1 rg /Cour 9 Tf) /DS ({junk}) /Contents (Hi) >>");
let (ap, fonts) = display_of(&annot);
assert_eq!(base_fonts(&fonts), ["Courier"]);
assert!(ap.contains("/Cour 9 Tf\n0 0 1 rg\n"), "{ap}");
}
/// New text in a box drawn in Courier stays Courier: its rich text says so.
#[test]
fn new_text_keeps_a_foreign_box_face() {
let mut doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
b"<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Cour 12 Tf) /Contents (Hi) >>",
]);
set_contents(&mut doc, 0, 0, "Hello", &meta("")).unwrap();
let d = &list(&doc, 0)[0];
assert_eq!(text(d, b"DS"), "font: Courier 12pt; text-align:left; color:#000000");
assert!(ap_content(&doc, d).contains("/Cour 12 Tf"));
}
/// A text box whose `/DS` overrides `/DA` reports the colour and size it is drawn in, and a colour
/// or size change keeps the other drawn value.
#[test]
fn text_box_properties_report_the_drawn_style() {
let doc = || {
document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
b"<< /Type /Annot /Subtype /FreeText /Rect [45 500 300 700] /Contents (Hi) /DA (1 0 0 rg /Helv 12 Tf) /DS (font: Helvetica 24pt; color:#0000FF) >>",
])
};
let mut d = doc();
let p = props(&d, 0, 0).unwrap();
assert_eq!((p.color, p.font_size), (Some([0.0, 0.0, 1.0]), Some(24.0)));
set_style(&mut d, 0, 0, StyleChange::new(Some([0.0, 1.0, 0.0]), None, None, None), &meta("")).unwrap();
let p = props(&d, 0, 0).unwrap();
assert_eq!((p.color, p.font_size), (Some([0.0, 1.0, 0.0]), Some(24.0)), "a colour change keeps the drawn size");
let mut d = doc();
set_font_size(&mut d, 0, 0, 18.0, &meta("")).unwrap();
let p = props(&d, 0, 0).unwrap();
assert_eq!((p.color, p.font_size), (Some([0.0, 0.0, 1.0]), Some(18.0)), "a size change keeps the drawn colour");
}
/// A redaction mark without `/QuadPoints` marks `/Rect` (Table 195). While marked it is outlined
/// only when it names an outline colour (an array `/OC` of three numbers); without one it draws
/// nothing. Marks with quadrilaterals and no `/OC` colour are outlined in `/C` as before.
#[test]
fn redaction_marks_without_quadpoints_outline_their_rect_in_their_oc_colour() {
let (ap, _) = display_of("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /IC [0 0 0] /OC [1 0 0] /C [0 0 1] >>");
assert_eq!(ap, "1 0 0 RG\n1 w\n45.5 539.5 m 164.5 539.5 l 164.5 470.5 l 45.5 470.5 l h S\n");
// No /OC colour: nothing to draw while marked (no /C outline, no /IC fill, no overlay text).
let blank = |annot: &str| {
let annot = format!("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] {annot} >>");
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
annot.as_bytes(),
]);
with_missing_appearances(&doc).is_none()
};
// An /OC that isn't three finite numbers is no colour; an optional-content dictionary keeps its meaning.
for annot in [
"/C [0.9 0.1 0.1]",
"/IC [0 0 0] /OverlayText (REDACTED) /DA (1 1 1 rg /Helv 10 Tf) /C [0.9 0.1 0.1]",
"/OC [1 0]",
"/OC [(a) 0 0]",
"/OC << /Type /OCG /Name (L) >>",
] {
assert!(blank(annot), "{annot}");
}
// With /OC, the interior colour and overlay text still wait for the redaction to be applied.
let (ap, _) =
display_of("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /OC [1 0 0] /IC [0 0 0] /OverlayText (X) /DA (1 1 1 rg /Helv 10 Tf) >>");
assert!(!ap.contains(" f\n") && !ap.contains("Tj"), "{ap}");
// With quadrilaterals and no /OC: the /C outline, as before.
let (ap, _) = display_of("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /QuadPoints [45 540 165 540 45 470 165 470] /C [0 0 1] >>");
assert_eq!(ap, "0 0 1 RG\n1 w\n45 540 m 165 540 l 165 470 l 45 470 l h S\n");
}
/// A redaction mark with quadrilaterals is outlined in its array `/OC` colour when it has one,
/// over `/C`; without `/OC` in `/C`, and without either in the default red.
#[test]
fn redaction_marks_with_quadpoints_take_their_outline_from_oc_then_c() {
let quad = |extra: &str| {
display_of(&format!("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /QuadPoints [45 540 165 540 45 470 165 470] {extra} >>")).0
};
let outline = "1 w\n45 540 m 165 540 l 165 470 l 45 470 l h S\n";
assert_eq!(quad("/OC [0 0 1]"), format!("0 0 1 RG\n{outline}"), "/OC blue");
assert_eq!(quad("/OC [0 0 1] /C [0 1 0]"), format!("0 0 1 RG\n{outline}"), "/OC over /C");
assert_eq!(quad("/C [0 1 0]"), format!("0 1 0 RG\n{outline}"), "/C only");
assert_eq!(quad(""), format!("0.89 0.13 0.13 RG\n{outline}"), "neither: red");
// An optional-content /OC is not a colour.
assert_eq!(quad("/OC << /Type /OCG /Name (L) >> /C [0 1 0]"), format!("0 1 0 RG\n{outline}"), "OCG /OC");
}
/// The six unapplied marks of the synthetic fixture the parity note's observation used: an array
/// `/OC` outlines the mark in its colour with or without `/QuadPoints`, beside `/C` or `/IC`; a
/// mark without `/QuadPoints` or `/OC` draws nothing while marked.
#[test]
fn unapplied_redaction_marks_follow_their_oc_outline_colour() {
let quad = "/QuadPoints [60 520 260 520 60 420 260 420]";
let cases = [
("/OC [0 0 1]", Some("0 0 1 RG")),
("/C [0 1 0]", None),
("/OC [0 0 1] /C [0 1 0]", Some("0 0 1 RG")),
("/OC [0 0 1] /IC [0 0 0]", Some("0 0 1 RG")),
("", None),
(&format!("/OC [0 0 1] {quad}"), Some("0 0 1 RG")),
];
for (extra, colour) in cases {
let annot = format!("<< /Type /Annot /Subtype /Redact /Rect [60 420 260 520] {extra} >>");
match colour {
Some(colour) => {
let (ap, _) = display_of(&annot);
assert!(ap.starts_with(&format!("{colour}\n1 w\n")) && ap.ends_with(" h S\n") && !ap.contains(" f\n"), "{extra}: {ap}");
}
None => {
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
annot.as_bytes(),
]);
assert!(with_missing_appearances(&doc).is_none(), "{extra}: drawn");
}
}
}
}
/// New text re-fits the box in the face and size it is drawn in (`/DA`, or `/DS` where it sets
/// one), so the rectangle holds every drawn line.
#[test]
fn new_text_refits_the_box_in_its_drawn_face_and_size() {
let bottom = |annot: &str, new_text: &str| {
let mut doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
annot.as_bytes(),
]);
set_contents(&mut doc, 0, 0, new_text, &meta("")).unwrap();
let d = &list(&doc, 0)[0];
let rect: Vec<f64> = d.get(b"Rect").and_then(|r| r.as_array()).unwrap().iter().filter_map(|x| x.as_f64()).collect();
assert_eq!(rect[3], 700.0, "the top edge stays");
(rect[1], ap_content(&doc, d).matches(" Tj").count())
};
// The default 1-pt border: a 3-pt pad, so 114 pt for the text; the box is lines × 1.2 × size + 8 tall.
let words = "iiiiiiiiii iiiiiiiiii";
let fits = |b: f64, want: f64| (b - want).abs() < 1e-9;
let (b, n) = bottom("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) /Contents (Hi) >>", words);
assert!(n == 1 && fits(b, 700.0 - 14.4 - 8.0), "Helvetica: one line, bottom {b}");
let (b, n) = bottom("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Cour 12 Tf) /Contents (Hi) >>", words);
assert!(n == 2 && fits(b, 700.0 - 2.0 * 14.4 - 8.0), "Courier: two lines, bottom {b}");
let (b, n) =
bottom("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) /DS (font: Helvetica 24pt) /Contents (Hi) >>", "Hi");
assert!(n == 1 && fits(b, 700.0 - 28.8 - 8.0), "/DS 24 pt: bottom {b}");
}
/// A colour or size change keeps the face a text box is drawn in: `/DA` keeps naming its font
/// resource (here Courier, and a `/DR` font that is Times Bold), and `/DS` says the same face
/// (ISO 32000-2 Table 177).
#[test]
fn a_colour_or_size_change_keeps_the_drawn_face() {
use pdfcraft_fonts::Std14;
let doc = |da: &str| {
let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 500 300 700] /Contents (Hi) /DA ({da}) >>");
document_of(&[
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /DR << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >> >> >> >> >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
annot.as_bytes(),
])
};
let face = |doc: &Document| {
let d = &list(doc, 0)[0];
text_style::text_style(d, text_style::dr_face(doc, d)).face
};
for (da, font, want) in [("0 g /Cour 12 Tf", "/Cour", Std14::Courier), ("0 g /F1 12 Tf", "/F1", Std14::TimesBold)] {
let mut d = doc(da);
assert_eq!(face(&d), want, "{da}");
set_style(&mut d, 0, 0, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
assert_eq!(face(&d), want, "colour change, {da}");
assert!(text(&list(&d, 0)[0], b"DA").contains(&format!("{font} 12 Tf")), "{da}");
set_font_size(&mut d, 0, 0, 18.0, &meta("")).unwrap();
assert_eq!(face(&d), want, "size change, {da}");
let p = props(&d, 0, 0).unwrap();
assert_eq!((p.color, p.font_size), (Some([1.0, 0.0, 0.0]), Some(18.0)), "{da}");
}
// The Courier box's appearance draws Courier.
let mut d = doc("0 g /Cour 12 Tf");
set_style(&mut d, 0, 0, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
assert!(ap_content(&d, &list(&d, 0)[0]).contains("/Cour 12 Tf"));
}
+420
View File
@@ -0,0 +1,420 @@
//! The face, size and colour a text box's text is drawn in when PdfCraft draws its appearance:
//! `/DA` (ISO 32000-2 Table 177; the font named there is looked up in the interactive form's
//! `/DR` font resources, §12.7.4.3) and `/DS`, the default style string (Table 177), which is
//! read for its CSS `font`, `font-family`, `font-size`, `font-weight`, `font-style` and `color`.
//!
//! Only the twelve standard text faces are drawn (Helvetica, Times and Courier, each regular,
//! bold, italic and bold italic, in WinAnsiEncoding): a font that isn't one of them is drawn in
//! Helvetica, as before. Symbol and ZapfDingbats are not text faces for this purpose.
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_fonts::{Std14, Std14Match as Match};
use crate::Rgb;
use crate::appearance::parse_da;
/// `/DS` past this many bytes is read only this far (a style string is a line or two).
const MAX_DS: usize = 4096;
/// Font sizes a text box is drawn at, as for `/DA`.
const MAX_SIZE: f64 = 400.0;
/// How a text box's text is drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextStyle {
pub face: Std14,
pub size: f64,
pub color: Rgb,
}
/// The style of `d`'s text: `/DA`, with what `/DS` sets taking its place. `dr_face` is the face
/// the `/DA` font resource names in the form's `/DR` ([`dr_face`]); `None` when it isn't there,
/// and the common resource names (`/Helv`, `/Cour`, `/TiRo`, …) are read instead.
pub fn text_style(d: &Dict, dr_face: Option<Std14>) -> TextStyle {
let (da_color, da_size) = parse_da(d);
let da_face = dr_face.or_else(|| da_font_name(d).as_deref().and_then(resource_face)).unwrap_or(Std14::Helvetica);
let mut style = TextStyle { face: da_face, size: da_size, color: da_color };
let Some(ds) = d.get(b"DS").and_then(Object::as_string).map(|s| s.to_text()) else { return style };
let ds = parse_ds(&ds);
if let Some(size) = ds.size {
style.size = size;
}
// `/DS` colours are 8-bit (`#RRGGBB`): one that rounds to the same as `/DA`'s keeps `/DA`'s.
if let Some(color) = ds.color
&& bytes(color) != bytes(da_color)
{
style.color = color;
}
let base = ds.face.unwrap_or(style.face);
style.face = styled(base, ds.bold.unwrap_or(is_bold(base)), ds.italic.unwrap_or(is_italic(base)));
style
}
/// The face the `/DA` font of `d` names in the document's `/AcroForm /DR /Font` (§12.7.4.3):
/// `Some` when that resource is there — the standard text face its `/BaseFont` names, or
/// Helvetica for any other font — and `None` when it isn't.
pub fn dr_face(doc: &Document, d: &Dict) -> Option<Std14> {
let name = da_font_name(d)?;
let root = doc.trailer().get(b"Root").map(|r| doc.resolve(r))?;
let form = root.as_dict()?.get(b"AcroForm").map(|f| doc.resolve(f))?;
let dr = form.as_dict()?.get(b"DR").map(|r| doc.resolve(r))?;
let fonts = dr.as_dict()?.get(b"Font").map(|f| doc.resolve(f))?;
let font = fonts.as_dict()?.get(name.as_bytes()).map(|f| doc.resolve(f))?;
let font = font.as_dict()?;
let base = font.name(b"BaseFont").map(String::from_utf8_lossy);
Some(base.as_deref().and_then(base_font_face).unwrap_or(Std14::Helvetica))
}
/// The font resource name of the last `Tf` in `/DA` (without the slash).
pub fn da_font_name(d: &Dict) -> Option<String> {
let da = d.get(b"DA").and_then(Object::as_string)?;
let da = String::from_utf8_lossy(&da.bytes);
let toks: Vec<&str> = da.split_whitespace().collect();
let tf = toks.iter().rposition(|t| *t == "Tf")?;
let name = toks.get(tf.checked_sub(2)?)?.strip_prefix('/')?;
(!name.is_empty()).then(|| name.to_string())
}
/// The face a font resource name stands for: the names PDF writers commonly give the standard
/// fonts in `/DR` (`Helv`, `HeBo`, `Cour`, `TiRo`, …), or a standard face's own name.
pub fn resource_face(name: &str) -> Option<Std14> {
Some(match name {
"Helv" => Std14::Helvetica,
"HeBo" => Std14::HelveticaBold,
"HeOb" | "HeIt" => Std14::HelveticaOblique,
"HeBO" | "HeBI" => Std14::HelveticaBoldOblique,
"Cour" => Std14::Courier,
"CoBo" => Std14::CourierBold,
"CoOb" | "CoIt" => Std14::CourierOblique,
"CoBO" | "CoBI" => Std14::CourierBoldOblique,
"TiRo" => Std14::TimesRoman,
"TiBo" => Std14::TimesBold,
"TiIt" => Std14::TimesItalic,
"TiBI" => Std14::TimesBoldItalic,
_ => return base_font_face(name),
})
}
/// The standard text face a `/BaseFont` (or family) name asks for: the spec's names and their
/// usual aliases (Arial, Times New Roman, Courier New), not a guess from a family hint.
fn base_font_face(name: &str) -> Option<Std14> {
match Std14::recognise(name)? {
(Std14::Symbol | Std14::ZapfDingbats, _) | (_, Match::FamilyHint) => None,
(face, Match::Alias) => Some(face),
}
}
/// `CSS` family names: the generic ones, else a standard face's name or alias.
fn family_face(name: &str) -> Option<Std14> {
let name = name.trim().trim_matches(|c| c == '"' || c == '\'').trim();
match name.to_ascii_lowercase().as_str() {
"serif" => Some(Std14::TimesRoman),
"monospace" => Some(Std14::Courier),
"sans-serif" => Some(Std14::Helvetica),
_ => base_font_face(name),
}
}
fn is_bold(f: Std14) -> bool {
matches!(
f,
Std14::HelveticaBold
| Std14::HelveticaBoldOblique
| Std14::TimesBold
| Std14::TimesBoldItalic
| Std14::CourierBold
| Std14::CourierBoldOblique
)
}
fn is_italic(f: Std14) -> bool {
matches!(
f,
Std14::HelveticaOblique
| Std14::HelveticaBoldOblique
| Std14::TimesItalic
| Std14::TimesBoldItalic
| Std14::CourierOblique
| Std14::CourierBoldOblique
)
}
/// `base`'s family in the given weight and slant.
fn styled(base: Std14, bold: bool, italic: bool) -> Std14 {
use Std14::*;
match (base, bold, italic) {
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, false) => TimesRoman,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, false) => TimesBold,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, true) => TimesItalic,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, true) => TimesBoldItalic,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, false, false) => Courier,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, true, false) => CourierBold,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, false, true) => CourierOblique,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, true, true) => CourierBoldOblique,
(_, false, false) => Helvetica,
(_, true, false) => HelveticaBold,
(_, false, true) => HelveticaOblique,
(_, true, true) => HelveticaBoldOblique,
}
}
/// The resource name an appearance stream gives `face` (the usual `/DR` names).
pub fn resource_name(face: Std14) -> &'static str {
match face {
Std14::HelveticaBold => "HeBo",
Std14::HelveticaOblique => "HeOb",
Std14::HelveticaBoldOblique => "HeBO",
Std14::TimesRoman => "TiRo",
Std14::TimesBold => "TiBo",
Std14::TimesItalic => "TiIt",
Std14::TimesBoldItalic => "TiBI",
Std14::Courier => "Cour",
Std14::CourierBold => "CoBo",
Std14::CourierOblique => "CoOb",
Std14::CourierBoldOblique => "CoBO",
_ => "Helv",
}
}
/// `face`'s `CSS` family, weight and style, for writing `/DS` and `/RC`.
pub fn css(face: Std14) -> (&'static str, &'static str, &'static str) {
let family = match styled(face, false, false) {
Std14::TimesRoman => "Times",
Std14::Courier => "Courier",
_ => "Helvetica",
};
(family, if is_bold(face) { "bold" } else { "normal" }, if is_italic(face) { "italic" } else { "normal" })
}
fn bytes(c: Rgb) -> [u8; 3] {
// Clamped to 0..=1 first, so the product is 0..=255 and the cast exact.
c.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8)
}
/// What a `/DS` string sets.
#[derive(Debug, Default, PartialEq)]
struct Ds {
face: Option<Std14>,
bold: Option<bool>,
italic: Option<bool>,
size: Option<f64>,
color: Option<Rgb>,
}
/// Read a default style string: `;`-separated `CSS` declarations. Unknown or malformed ones are
/// skipped; later ones win.
fn parse_ds(ds: &str) -> Ds {
let end = ds.char_indices().map(|(i, _)| i).find(|i| *i >= MAX_DS).unwrap_or(ds.len());
let ds = ds.get(..end).unwrap_or_default();
let mut out = Ds::default();
for decl in ds.split(';') {
let Some((prop, value)) = decl.split_once(':') else { continue };
let value = value.trim();
match prop.trim().to_ascii_lowercase().as_str() {
"font" => font_shorthand(value, &mut out),
"font-family" => {
if let Some(face) = value.split(',').find_map(family_face) {
out.face = Some(face);
}
}
"font-size" => {
if let Some(size) = font_size(value, true) {
out.size = Some(size);
}
}
"font-weight" => {
if let Some(bold) = weight(value) {
out.bold = Some(bold);
}
}
"font-style" => {
if let Some(italic) = slant(value) {
out.italic = Some(italic);
}
}
"color" => {
if let Some(c) = css_color(value) {
out.color = Some(c);
}
}
_ => {}
}
}
out
}
/// `font: [style] [weight] size[/line-height] family[, family…]`, read leniently: the size is
/// the token with a unit, keywords set weight and slant, and the rest is the family list (some
/// writers put the family first).
fn font_shorthand(value: &str, out: &mut Ds) {
let mut family = Vec::new();
for tok in value.split_whitespace() {
if let Some(size) = font_size(tok, false) {
out.size = Some(size);
} else if let Some(bold) = weight(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) {
out.bold = Some(bold);
} else if let Some(italic) = slant(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) {
out.italic = Some(italic);
} else if !matches!(tok.to_ascii_lowercase().as_str(), "normal" | "small-caps") {
family.push(tok);
}
}
if let Some(face) = family.join(" ").split(',').find_map(family_face) {
out.face = Some(face);
}
}
/// A size in points: `14pt` (with a line height, `14pt/16pt`), or a bare number where
/// `unitless` allows it. Positive and finite only; capped as `/DA` sizes are.
fn font_size(tok: &str, unitless: bool) -> Option<f64> {
let tok = tok.split('/').next()?.trim();
let num = match tok.strip_suffix("pt").or_else(|| tok.strip_suffix("PT")) {
Some(n) => n,
None if unitless => tok,
None => return None,
};
num.parse::<f64>().ok().filter(|s| s.is_finite() && *s > 0.0).map(|s| s.min(MAX_SIZE))
}
fn weight(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"bold" | "bolder" | "600" | "700" | "800" | "900" => Some(true),
"normal" | "lighter" | "100" | "200" | "300" | "400" | "500" => Some(false),
_ => None,
}
}
fn slant(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"italic" | "oblique" => Some(true),
"normal" => Some(false),
_ => None,
}
}
/// `#RRGGBB`, `#RGB` or `rgb(r, g, b)` (0–255 or percentages).
fn css_color(v: &str) -> Option<Rgb> {
let v = v.trim();
if let Some(hex) = v.strip_prefix('#') {
let digit = |c: char| c.to_digit(16).map(f64::from);
let ds: Vec<f64> = hex.chars().map(digit).collect::<Option<_>>()?;
return match ds.as_slice() {
[r, g, b] => Some([r * 17.0 / 255.0, g * 17.0 / 255.0, b * 17.0 / 255.0]),
[r1, r0, g1, g0, b1, b0] => Some([(r1 * 16.0 + r0) / 255.0, (g1 * 16.0 + g0) / 255.0, (b1 * 16.0 + b0) / 255.0]),
_ => None,
};
}
let lower = v.to_ascii_lowercase();
let inner = lower.strip_prefix("rgb(")?.strip_suffix(')')?;
let parts: Vec<f64> = inner
.split(',')
.map(|p| {
let p = p.trim();
let x = match p.strip_suffix('%') {
Some(pc) => pc.trim().parse::<f64>().ok()? / 100.0,
None => p.parse::<f64>().ok()? / 255.0,
};
x.is_finite().then(|| x.clamp(0.0, 1.0))
})
.collect::<Option<_>>()?;
match parts.as_slice() {
[r, g, b] => Some([*r, *g, *b]),
_ => None,
}
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::PdfString;
use super::*;
fn dict(da: &str, ds: Option<&str>) -> Dict {
let mut d = Dict::new();
d.set(b"DA".to_vec(), PdfString::literal(da.as_bytes().to_vec()));
if let Some(ds) = ds {
d.set(b"DS".to_vec(), PdfString::text(ds));
}
d
}
#[test]
fn da_resource_names_pick_the_standard_faces() {
assert_eq!(text_style(&dict("0 g /Cour 12 Tf", None), None).face, Std14::Courier);
assert_eq!(text_style(&dict("1 0 0 rg /TiRo 16 Tf", None), None).face, Std14::TimesRoman);
assert_eq!(text_style(&dict("/HeBo 9 Tf 0 g", None), None).face, Std14::HelveticaBold);
assert_eq!(text_style(&dict("/Courier-Bold 9 Tf", None), None).face, Std14::CourierBold);
// Unknown, symbolic and family-hint-only names keep Helvetica.
for da in ["/F1 12 Tf", "/ZaDb 12 Tf", "/Symbol 12 Tf", "/MySerif 12 Tf", "12 Tf", "", "/ Tf"] {
assert_eq!(text_style(&dict(da, None), None).face, Std14::Helvetica, "{da:?}");
}
// A `/DR` lookup outranks the name.
assert_eq!(text_style(&dict("/Cour 12 Tf", None), Some(Std14::TimesRoman)).face, Std14::TimesRoman);
}
#[test]
fn ds_font_size_and_colour_win_over_da() {
let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: Times 14pt; color: #008000")), None);
assert_eq!((s.face, s.size), (Std14::TimesRoman, 14.0));
assert!((s.color[1] - 128.0 / 255.0).abs() < 1e-9 && s.color[0] == 0.0 && s.color[2] == 0.0, "{:?}", s.color);
let s = text_style(
&dict(
"1 0 0 rg /Cour 9 Tf",
Some("font-family: 'Helvetica', sans-serif; font-size: 20; font-weight: bold; font-style: italic; color: rgb(0, 0, 255)"),
),
None,
);
assert_eq!((s.face, s.size, s.color), (Std14::HelveticaBoldOblique, 20.0, [0.0, 0.0, 1.0]));
// CSS order, a line height, a #RGB colour and a generic family.
let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: italic bold 10pt/12pt serif; color:#f00")), None);
assert_eq!((s.face, s.size, s.color), (Std14::TimesBoldItalic, 10.0, [1.0, 0.0, 0.0]));
// Bold from the style keeps the DA family.
assert_eq!(text_style(&dict("/Cour 12 Tf", Some("font-weight:700")), None).face, Std14::CourierBold);
}
/// PdfCraft writes `/DS` from `/DA` (8-bit colour): `/DA`'s exact colour, size and face stay.
#[test]
fn pdfcraft_written_ds_matches_da() {
let s = text_style(&dict("0.1 0.2 0.3 rg /Helv 12.5 Tf", Some("font: Helvetica 12.5pt; text-align:left; color:#1A334D")), None);
assert_eq!(s, TextStyle { face: Std14::Helvetica, size: 12.5, color: [0.1, 0.2, 0.3] });
}
#[test]
fn malformed_ds_falls_back_to_da() {
let da = TextStyle { face: Std14::Courier, size: 9.0, color: [1.0, 0.0, 0.0] };
let huge = format!("color: #00ff00;{}", "x".repeat(1 << 20));
let past_cap = format!("{}; font-size: 30pt", " ".repeat(MAX_DS));
for ds in [
"font-size: NaN",
"font-size: inf",
"font-size: -3pt",
"font-size: 0",
"font: NaNpt Zzz",
"font: 1e999pt",
"color: #12",
"color: #zzzzzz",
"color: rgb(1, 2)",
"color: rgb(NaN, 0, 0)",
"color: #ff0000é",
":::;;;",
"font-family: Wingdings, 'Comic Sans'",
"é: ü; ;",
past_cap.as_str(),
] {
assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(ds)), None), da, "{ds:?}");
}
// Read up to the cap: what comes first still counts, and nothing past it is scanned.
assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(&huge)), None).color, [0.0, 1.0, 0.0]);
// Sizes are capped as /DA's are.
assert_eq!(text_style(&dict("/Helv 9 Tf", Some("font-size: 1e300pt")), None).size, MAX_SIZE);
}
#[test]
fn css_round_trips_through_the_parser() {
for face in [Std14::Helvetica, Std14::TimesBold, Std14::CourierOblique, Std14::HelveticaBoldOblique, Std14::TimesBoldItalic] {
let (family, weight, style) = css(face);
let ds = format!("font-family:{family};font-weight:{weight};font-style:{style}");
assert_eq!(text_style(&dict("/Helv 9 Tf", Some(&ds)), None).face, face);
}
}
}
+38
View File
@@ -7076,6 +7076,44 @@ fn a_line_without_an_appearance_is_drawn_at_its_leader_length() {
assert_eq!(px(100, 200), vec![255, 255, 255, 255], "nothing along /L");
}
/// A text box without `/AP`, `/BS` or `/Border` is drawn with the `/Border` default, a 1-pt
/// border in its text colour (ISO 32000-2 Table 166), and a redaction mark without `/AP` or
/// `/QuadPoints` is outlined around `/Rect` (Table 195), here in its array `/OC` colour.
#[test]
fn text_boxes_and_redaction_marks_without_appearances_show_their_outlines() {
let objs: Vec<&[u8]> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
b"<< /Type /Annot /Subtype /FreeText /Rect [20 200 180 260] /Contents (Box) /DA (1 0 0 rg /Helv 10 Tf) >>",
b"<< /Type /Annot /Subtype /Redact /Rect [20 20 180 120] /IC [0 0 0] /OC [0 0 1] >>",
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(o);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("outlines.pdf", None, Arc::new(bytes), None).expect("opens");
let p = shown(&s, id, 0);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec();
// y down: the text box spans rows 40..100, the redaction mark rows 180..280; both left edges
// are column 20.
assert_eq!(px(20, 70), [255, 0, 0], "the text box's left border, in its /DA colour");
assert_eq!(px(100, 99), [255, 0, 0], "the text box's bottom border");
assert_eq!(px(20, 230), [0, 0, 255], "the redaction mark's outline, in its /OC colour");
assert_eq!(px(100, 230), [255, 255, 255], "no fill before the redaction is applied");
}
/// A stamp's `/Rect` and the colour family expected inside it.
type StampRect = ([f64; 4], char);
+1 -1
View File
@@ -30,7 +30,7 @@ pub use script::{
pub use shaping::{
ShapedCluster, ShapedScript, arabic_glyph, arabic_has, hebrew_has, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, thai_has,
};
pub use std14::Std14;
pub use std14::{Match as Std14Match, Std14};
/// Advance of `s` in Helvetica at `size` points, using the exact standard-14 metrics.
///
+2 -2
View File
@@ -818,8 +818,8 @@ milestone = "M2"
status = "shipped"
commands = []
tools = []
evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"]
notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)"
evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::text_boxes_and_redaction_marks_without_appearances_show_their_outlines", "crates/annot/src/tests.rs::free_text_da_fonts_draw_in_their_standard_face", "crates/annot/src/tests.rs::free_text_ds_style_is_drawn", "crates/annot/src/tests.rs::redaction_marks_with_quadpoints_take_their_outline_from_oc_then_c", "crates/annot/src/tests.rs::unapplied_redaction_marks_follow_their_oc_outline_colour", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"]
notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text boxes without /BS or /Border get the /Border default 1-pt border (ISO 32000-2 Table 166), their /DA font when /DR or its resource name makes it one of the twelve standard text faces (others stay Helvetica), and /DS font, size and colour over /DA (no /RC rich text yet); unapplied redaction marks without /AP are outlined in an array /OC colour (three finite numbers) when they have one, with or without /QuadPoints; without one, marks with /QuadPoints keep /C (else red) and marks without /QuadPoints (Table 195: /Rect is the region) draw nothing; an /OC dictionary stays optional content (Table 166). Basis: ISO 32000-2 §12.5.6.23 says only that before redaction is applied the user can see, move and redefine these annotations, and Table 195 has no outline-colour entry, so the colour comes from black-box observation of two synthetic fixtures. noap3.pdf (generator attached to #4364), rows J/K/L, unapplied, without /QuadPoints: Adobe Acrobat Pro J nothing, K red outline, L nothing; Chrome (PDFium) and Edge nothing on all three; PDF-XChange Editor and Right PDF Editor a red outline on all three. oc.pdf, six marks without /AP (1 /OC blue; 2 /C green; 3 /OC blue + /C green; 4 /OC blue + /IC black; 5 no colour; 6 /OC blue + /QuadPoints; rebuilt inline in unapplied_redaction_marks_follow_their_oc_outline_colour): Acrobat blue on 1, 3, 4 and 6 and nothing on 2 and 5; Chrome and Edge nothing on all six; PDF-XChange Editor green on 2 and 3, nothing on the others; Right PDF Editor red on all six. The three readers tested that support redaction marks disagree, so PdfCraft follows the /OC colour, the one Acrobat uses; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)"
[[feature]]
id = "view.render-error-isolation"