mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
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:
1 parent
1d1cbc4ec9
commit
ff886283f8
10 files changed
+870
-40
No files matched your search
@@ -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()));
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -46,6 +46,7 @@ pub use types::{
|
||||
|
||||
pub mod appearance;
|
||||
pub mod links;
|
||||
pub mod text_style;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
+42
-17
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """);
|
||||
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
@@ -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"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user