mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 04:51:37 +00:00
Print production: Fix hairlines (dialog, edit, tool) (#1906)
This commit is contained in:
1 parent
13f5f00bca
commit
e63736d2e7
33 files changed
+1608
-25
No files matched your search
@@ -747,6 +747,7 @@ impl Automation {
|
||||
"content_delete" => self.content_delete(&a)?,
|
||||
"content_copy" => self.content_copy(&a)?,
|
||||
"doc_print" => self.doc_print(&a)?,
|
||||
"doc_fix_hairlines" => self.fix_hairlines(&a)?,
|
||||
"attachment_add" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Print production ▸ Fix hairlines as a tool: strokes narrower than a threshold on the page get a
|
||||
//! width (`doc_fix_hairlines`).
|
||||
|
||||
use pdfcraft_engine::{Edit, Hairlines};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError};
|
||||
|
||||
impl Automation {
|
||||
/// Strokes thinner than `narrower_than` points on the page (after every transformation;
|
||||
/// zero-width lines always) are given `width` points; Type 3 glyphs and tiling patterns on
|
||||
/// request. Nothing to fix is not an error: `fixed` is 0 and no edit is made. `elsewhere`
|
||||
/// counts the glyphs and patterns changed that something outside `pages` also uses.
|
||||
pub(crate) fn fix_hairlines(&mut self, a: &Args) -> Result<Value> {
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let settings = Hairlines {
|
||||
narrower_than: a.opt_num("narrower_than")?.unwrap_or(0.003 * 72.0),
|
||||
width: a.opt_num("width")?.unwrap_or(0.01 * 72.0),
|
||||
type3: a.opt_bool("type3")?.unwrap_or(false),
|
||||
patterns: a.opt_bool("patterns")?.unwrap_or(false),
|
||||
};
|
||||
if !settings.in_range() {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"narrower_than must be 0 or more and width above 0, both at most {} (points)",
|
||||
Hairlines::MAX_POINTS
|
||||
)));
|
||||
}
|
||||
let id = self.doc(a)?.id;
|
||||
let found = self.session.count_hairlines(id, &pages, &settings);
|
||||
if found.lines == 0 {
|
||||
return Ok(json!({ "fixed": 0, "pages": 0, "elsewhere": 0 }));
|
||||
}
|
||||
let r = self.apply(a, Edit::FixHairlines { pages, settings })?;
|
||||
Ok(json!({ "fixed": found.lines, "pages": found.pages, "elsewhere": found.elsewhere, "undo": r["undo"] }))
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ mod content;
|
||||
mod conventions;
|
||||
pub mod file_access;
|
||||
mod forms;
|
||||
mod hairlines;
|
||||
mod layers;
|
||||
mod links;
|
||||
#[cfg(feature = "mcp")]
|
||||
|
||||
@@ -154,6 +154,17 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"dpi": {"type":"number","minimum":72,"maximum":600},
|
||||
"language": {"type":"string"}, "skip_text_pages": {"type":"boolean"}
|
||||
}), &["paths","out_dir","steps"])),
|
||||
t("doc_fix_hairlines", "Fix hairlines", "Print production: strokes narrower than narrower_than points on the page (after every transformation; zero-width lines always) are given width points, in page content and form XObjects; Type 3 glyphs and tiling patterns only when asked, changed in place wherever they are used. Both widths at most 10. Returns fixed (strokes), pages, and elsewhere (glyphs and patterns changed that something outside pages also uses); nothing to fix gives 0 and no edit.")
|
||||
.cmd("prepress.hairlines")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "pages": pages("to fix (default all)"),
|
||||
"narrower_than": { "type": "number", "minimum": 0, "description": "Points on the page (default 0.216)." },
|
||||
"width": { "type": "number", "exclusiveMinimum": 0, "description": "Points on the page (default 0.72)." },
|
||||
"type3": { "type": "boolean" }, "patterns": { "type": "boolean" },
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("attachment_add", "Attach a file", "Attach the file at path to the document (document level, the Attachments panel; not a paper-clip comment on a page). name defaults to the file's name; the attachment's key, unique in the document, is returned. Undoable.")
|
||||
.cmd("edit.attach")
|
||||
.with(schema(json!({"doc": doc(), "path": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "path"])),
|
||||
|
||||
@@ -10689,6 +10689,30 @@ fn a_root_less_write_output_is_atomic_too() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Fix hairlines through the tools: counts what changed, and reports zero without an edit.
|
||||
#[test]
|
||||
fn fix_hairlines_through_tools() {
|
||||
let dir = workdir("hairlines");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
// The fixture's text pages have no strokes.
|
||||
let r = ok(&mut a, "doc_fix_hairlines", json!({ "doc": doc }));
|
||||
assert_eq!((r["fixed"].as_u64(), r["undo"].is_null()), (Some(0), true));
|
||||
// A zero-width rule drawn by the page content tools counts as a hairline.
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [10, 10], "to": [100, 10], "width": 0 }));
|
||||
let r = ok(&mut a, "doc_fix_hairlines", json!({ "doc": doc, "pages": [1] }));
|
||||
// Comments are annotations, not page content: still nothing in the page's own content.
|
||||
assert_eq!(r["fixed"].as_u64(), Some(0), "{r}");
|
||||
assert_eq!(r["elsewhere"].as_u64(), Some(0), "{r}");
|
||||
for bad in
|
||||
[json!({ "width": 0 }), json!({ "width": -1 }), json!({ "width": 10000 }), json!({ "narrower_than": -1 }), json!({ "narrower_than": 10000 })]
|
||||
{
|
||||
let mut args = bad.clone();
|
||||
args["doc"] = json!(doc);
|
||||
assert!(matches!(a.call("doc_fix_hairlines", &args), Err(ToolError::InvalidArgs(_))), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_and_deleting_files_through_the_tools() {
|
||||
let dir = workdir("attach");
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
//! Fix hairlines (Acrobat's Print production ▸ Fix Hairlines; Fix hairlines): strokes thinner
|
||||
//! than a printer holds - zero-width lines, and lines whose width on the page, after every
|
||||
//! transformation, is below a threshold - are given a chosen width. Page content and the Form
|
||||
//! XObjects it draws are rewritten; Type 3 glyphs and tiling patterns on request.
|
||||
//!
|
||||
//! A form is rewritten for the scale and line width it is drawn under. One drawn under several
|
||||
//! (or drawn by a page that is not being fixed) is rewritten as a copy for each use that needs
|
||||
//! one, and the resources of what draws it point at the copy, so no other use changes.
|
||||
//!
|
||||
//! Type 3 glyphs and tiling patterns are each a content stream of their own, reached from the
|
||||
//! resources of the page and of the forms it draws. They are rewritten in place, so a page
|
||||
//! outside the run that uses one changes too; [`Fixed::elsewhere`] counts those.
|
||||
|
||||
use pdfcraft_content::{Op, parse, serialize_ops};
|
||||
use pdfcraft_cos::{Dict, Document, Name, ObjRef, Object, Stream};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::page_streams::PageStreams;
|
||||
use crate::{EditError, check, n, page_list};
|
||||
|
||||
/// The stream `r` holds, if it is one.
|
||||
fn stream_of(doc: &Document, r: ObjRef) -> Option<Stream> {
|
||||
match &*doc.get(r) {
|
||||
Object::Stream(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What counts as a hairline and what it becomes, in points on the page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Hairlines {
|
||||
/// Lines narrower than this (points, after the page's transformations) are replaced;
|
||||
/// zero-width lines always are. Acrobat's default is 0.003 in.
|
||||
pub narrower_than: f64,
|
||||
/// The width they are given (points on the page). Acrobat's default is 0.01 in.
|
||||
pub width: f64,
|
||||
/// Also the strokes inside Type 3 font glyphs.
|
||||
pub type3: bool,
|
||||
/// Also the strokes inside tiling patterns.
|
||||
pub patterns: bool,
|
||||
}
|
||||
|
||||
impl Default for Hairlines {
|
||||
fn default() -> Self {
|
||||
Self { narrower_than: 0.003 * 72.0, width: 0.01 * 72.0, type3: false, patterns: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Hairlines {
|
||||
/// The widest either width may be, in points: past this it is no hairline.
|
||||
pub const MAX_POINTS: f64 = 10.0;
|
||||
|
||||
/// Whether both widths are numbers a run accepts: a width above 0, a threshold of 0 or
|
||||
/// more, neither past [`Self::MAX_POINTS`].
|
||||
pub fn in_range(&self) -> bool {
|
||||
(0.0..=Self::MAX_POINTS).contains(&self.narrower_than) && self.width > 0.0 && self.width <= Self::MAX_POINTS
|
||||
}
|
||||
}
|
||||
|
||||
/// What a run changed.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Fixed {
|
||||
/// Strokes given a new width.
|
||||
pub lines: usize,
|
||||
/// Pages on which something changed.
|
||||
pub pages: usize,
|
||||
/// Type 3 glyphs and tiling patterns changed that a page outside the run, or an
|
||||
/// annotation's appearance, also uses: what those draw changed too.
|
||||
pub elsewhere: usize,
|
||||
}
|
||||
|
||||
/// The uniform scale a matrix applies to lengths (the geometric mean of its two axes).
|
||||
fn scale_of(m: [f64; 6]) -> f64 {
|
||||
(m[0] * m[3] - m[1] * m[2]).abs().sqrt()
|
||||
}
|
||||
|
||||
fn mul(a: [f64; 6], b: [f64; 6]) -> [f64; 6] {
|
||||
// a then b.
|
||||
[
|
||||
a[0] * b[0] + a[1] * b[2],
|
||||
a[0] * b[1] + a[1] * b[3],
|
||||
a[2] * b[0] + a[3] * b[2],
|
||||
a[2] * b[1] + a[3] * b[3],
|
||||
a[4] * b[0] + a[5] * b[2] + b[4],
|
||||
a[4] * b[1] + a[5] * b[3] + b[5],
|
||||
]
|
||||
}
|
||||
|
||||
/// The graphics state a stream leaves for the next one on the same page: CTM and line width,
|
||||
/// with the q/Q stack, and whether a path is still being built.
|
||||
#[derive(Clone, Debug)]
|
||||
struct State {
|
||||
ctm: [f64; 6],
|
||||
width: f64,
|
||||
stack: Vec<([f64; 6], f64)>,
|
||||
in_path: bool,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// A stream drawn under `base` with line width `width` in effect.
|
||||
fn new(base: [f64; 6], width: f64) -> Self {
|
||||
Self { ctm: base, width, stack: Vec::new(), in_path: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// All a form's rewrite depends on: the scale it is drawn under and the line width in effect
|
||||
/// at its `Do` (as bits, so it can be a map key).
|
||||
type Key = (u64, u64);
|
||||
|
||||
fn key_of(ctm: [f64; 6], width: f64) -> Key {
|
||||
(scale_of(ctm).to_bits(), width.to_bits())
|
||||
}
|
||||
|
||||
/// A Form XObject a stream draws: the resource name it is drawn by, the object, and the CTM
|
||||
/// and line width at its `Do`.
|
||||
struct Drawn {
|
||||
name: Name,
|
||||
r: ObjRef,
|
||||
ctm: [f64; 6],
|
||||
width: f64,
|
||||
}
|
||||
|
||||
/// The names a stream's `Do`s are given instead, by the name they had and the key they are
|
||||
/// drawn under.
|
||||
type Renames = HashMap<(Name, Key), Name>;
|
||||
|
||||
/// How deep forms drawing forms are followed, and how many form rewrites one run makes; past
|
||||
/// either, a form is drawn as it is.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
const MAX_FORMS: usize = 4096;
|
||||
|
||||
/// The width `/name gs` sets, when its ExtGState has an `/LW`.
|
||||
fn line_width_of(doc: &Document, gstates: Option<&Dict>, name: &[u8]) -> Option<f64> {
|
||||
let gs = doc.resolve(gstates?.get(name)?);
|
||||
let lw = gs.as_dict()?.get(b"LW")?;
|
||||
doc.resolve(lw).as_f64().filter(|w| w.is_finite())
|
||||
}
|
||||
|
||||
/// Rewrite one content stream drawn under `state` (the CTM when it starts, scale only
|
||||
/// matters), leaving in it the state at the stream's end. A thin stroke gets a `w` of its own
|
||||
/// before its path begins (graphics-state operators are not allowed inside a path) and the
|
||||
/// old width back after it is painted, so nothing else drawn changes width.
|
||||
/// `forms` collects the Form XObjects it draws, with the CTM and width each is drawn under,
|
||||
/// for the caller to rewrite in turn; a `Do` listed in `renames` draws the name given there.
|
||||
/// Returns the new bytes when anything changed, and the count.
|
||||
fn rewrite(
|
||||
data: &[u8],
|
||||
state: &mut State,
|
||||
h: &Hairlines,
|
||||
resources: Option<&Dict>,
|
||||
doc: &Document,
|
||||
forms: &mut Vec<Drawn>,
|
||||
renames: &Renames,
|
||||
) -> (Option<Vec<u8>>, usize) {
|
||||
let parsed = parse(data);
|
||||
let mut ops: Vec<Op> = parsed.ops;
|
||||
let mut fixed = 0usize;
|
||||
let mut changed = false;
|
||||
// Where in `out` the path being built began (None when it began in an earlier stream).
|
||||
let mut path_start: Option<usize> = None;
|
||||
let mut out: Vec<Op> = Vec::with_capacity(ops.len() + 8);
|
||||
let dict_of = |key: &[u8]| resources.and_then(|r| r.get(key)).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
|
||||
let xobjects = dict_of(&b"XObject"[..]);
|
||||
let gstates = dict_of(&b"ExtGState"[..]);
|
||||
for mut op in ops.drain(..) {
|
||||
match op.op.as_slice() {
|
||||
b"q" => {
|
||||
state.stack.push((state.ctm, state.width));
|
||||
if state.stack.len() > 512 {
|
||||
state.stack.remove(0);
|
||||
}
|
||||
}
|
||||
b"Q" => {
|
||||
if let Some((c, w)) = state.stack.pop() {
|
||||
state.ctm = c;
|
||||
state.width = w;
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
if let Some(m) = op.nums::<6>() {
|
||||
state.ctm = mul(m, state.ctm);
|
||||
}
|
||||
}
|
||||
b"w" => {
|
||||
if let Some([w]) = op.nums::<1>() {
|
||||
state.width = w;
|
||||
}
|
||||
}
|
||||
b"gs" => {
|
||||
// An ExtGState's /LW sets the width as `w` does (so the width put back after
|
||||
// a fixed stroke is the right one).
|
||||
if let Some(Object::Name(name)) = op.operands.first()
|
||||
&& let Some(w) = line_width_of(doc, gstates.as_ref(), name)
|
||||
{
|
||||
state.width = w;
|
||||
}
|
||||
}
|
||||
b"m" | b"re" => {
|
||||
if !state.in_path {
|
||||
state.in_path = true;
|
||||
path_start = Some(out.len());
|
||||
}
|
||||
}
|
||||
b"Do" => {
|
||||
if let Some(Object::Name(name)) = op.operands.first()
|
||||
&& let Some(x) = xobjects.as_ref().and_then(|x| x.get(name))
|
||||
&& let Some(r) = x.as_ref()
|
||||
&& matches!(&*doc.get(r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form"))
|
||||
{
|
||||
let name = name.clone();
|
||||
if !renames.is_empty()
|
||||
&& let Some(to) = renames.get(&(name.clone(), key_of(state.ctm, state.width)))
|
||||
&& let Some(first) = op.operands.first_mut()
|
||||
{
|
||||
*first = Object::Name(to.clone());
|
||||
changed = true;
|
||||
}
|
||||
forms.push(Drawn { name, r, ctm: state.ctm, width: state.width });
|
||||
}
|
||||
}
|
||||
b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" => {
|
||||
let scale = scale_of(state.ctm);
|
||||
let on_page = state.width * scale;
|
||||
let thin = state.width <= 0.0 || (scale > 0.0 && on_page < h.narrower_than);
|
||||
// A path begun in an earlier stream cannot get its `w` before it here; it is
|
||||
// left as it is rather than given an operator inside the path.
|
||||
let at = match path_start {
|
||||
Some(i) => Some(i),
|
||||
None if !state.in_path => Some(out.len()),
|
||||
None => None,
|
||||
};
|
||||
state.in_path = false;
|
||||
path_start = None;
|
||||
if thin
|
||||
&& scale > 0.0
|
||||
&& let Some(at) = at
|
||||
{
|
||||
let new_w = h.width / scale;
|
||||
out.insert(at, Op::new("w", vec![Object::Real(new_w)]));
|
||||
out.push(op);
|
||||
out.push(Op::new("w", vec![Object::Real(state.width)]));
|
||||
fixed += 1;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
b"f" | b"F" | b"f*" | b"n" => {
|
||||
state.in_path = false;
|
||||
path_start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out.push(op);
|
||||
}
|
||||
(changed.then(|| serialize_ops(&out)), fixed)
|
||||
}
|
||||
|
||||
/// What a run keeps across pages.
|
||||
struct Run {
|
||||
/// Each form already rewritten under a key, and the object drawn for it there.
|
||||
done: HashMap<(ObjRef, Key), ObjRef>,
|
||||
/// The forms drawn where this run does not rewrite: they are never changed in place.
|
||||
shared: HashSet<ObjRef>,
|
||||
/// The forms already drawn as themselves: `true` when rewritten in place, `false` when
|
||||
/// left as they were. Any other use that needs something else gets a copy.
|
||||
used: HashMap<ObjRef, bool>,
|
||||
/// Each form's stream as it was before the run.
|
||||
original: HashMap<ObjRef, Stream>,
|
||||
/// Form rewrites left.
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
/// The XObjects (forms and images, and those the forms draw), tiling patterns and Type 3
|
||||
/// glyphs that the pages not in `pages` use, and those any page's annotation appearances use
|
||||
/// (this run does not rewrite them): rewriting one of those in place would change what they
|
||||
/// show.
|
||||
fn drawn_elsewhere(doc: &Document, pages: &[usize]) -> HashSet<ObjRef> {
|
||||
let mut seen: HashSet<ObjRef> = HashSet::new();
|
||||
let mut todo: Vec<Object> = Vec::new();
|
||||
let xobjects_of = |res: Option<&Object>, todo: &mut Vec<Object>| {
|
||||
let Some(res) = res.map(|r| doc.resolve(r)) else { return };
|
||||
let Some(res) = res.as_dict() else { return };
|
||||
let dict_at = |key: &[u8]| res.get(key).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
|
||||
for dict in [dict_at(b"XObject"), dict_at(b"Pattern")].into_iter().flatten() {
|
||||
todo.extend(dict.iter().map(|(_, o)| o.clone()));
|
||||
}
|
||||
for (_, font) in dict_at(b"Font").iter().flat_map(Dict::iter) {
|
||||
let font = doc.resolve(font);
|
||||
if let Some(procs) = font.as_dict().and_then(|f| f.get(b"CharProcs")).map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) {
|
||||
todo.extend(procs.iter().map(|(_, g)| g.clone()));
|
||||
}
|
||||
}
|
||||
};
|
||||
for (i, page) in page_list(doc).into_iter().enumerate() {
|
||||
if !pages.contains(&i) {
|
||||
xobjects_of(page.dict.get(b"Resources"), &mut todo);
|
||||
}
|
||||
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
for a in annots {
|
||||
let Some(ap) = doc.resolve(&a).as_dict().and_then(|d| d.get(b"AP").cloned()) else { continue };
|
||||
let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { continue };
|
||||
for (_, v) in ap.iter() {
|
||||
match &*doc.resolve(v) {
|
||||
Object::Dict(states) => todo.extend(states.iter().map(|(_, s)| s.clone())),
|
||||
_ => todo.push(v.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Some(o) = todo.pop() {
|
||||
let Some(r) = o.as_ref() else { continue };
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
let res = match &*doc.get(r) {
|
||||
Object::Stream(s) => s.dict.get(b"Resources").cloned(),
|
||||
_ => None,
|
||||
};
|
||||
xobjects_of(res.as_ref(), &mut todo);
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// A name for another copy drawn by `name` that `xobjects` does not hold yet.
|
||||
fn fresh_name(xobjects: &Dict, name: &[u8]) -> Name {
|
||||
let mut i = 1usize;
|
||||
loop {
|
||||
let mut candidate = name.to_vec();
|
||||
candidate.extend_from_slice(format!("_hl{i}").as_bytes());
|
||||
if !xobjects.contains(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite one drawing context - a page's streams, or a form's - that starts in `start`, and
|
||||
/// the forms it draws. Returns each stream's new bytes (`None` where it did not change), the
|
||||
/// strokes fixed (in the forms too), and the `/XObject` dictionary for the context's resources
|
||||
/// when its names must now point at other objects.
|
||||
fn fix_context(
|
||||
doc: &mut Document,
|
||||
run: &mut Run,
|
||||
h: &Hairlines,
|
||||
data: &[Vec<u8>],
|
||||
start: &State,
|
||||
resources: Option<&Dict>,
|
||||
depth: usize,
|
||||
) -> (Vec<Option<Vec<u8>>>, usize, Option<Dict>) {
|
||||
let none = Renames::new();
|
||||
let mut drawn: Vec<Drawn> = Vec::new();
|
||||
let mut state = start.clone();
|
||||
let mut new: Vec<Option<Vec<u8>>> = Vec::with_capacity(data.len());
|
||||
let mut fixed = 0usize;
|
||||
for d in data {
|
||||
let (bytes, count) = rewrite(d, &mut state, h, resources, doc, &mut drawn, &none);
|
||||
fixed += count;
|
||||
new.push(bytes);
|
||||
}
|
||||
if drawn.is_empty() {
|
||||
return (new, fixed, None);
|
||||
}
|
||||
let Some(xo) = resources.and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else {
|
||||
return (new, fixed, None);
|
||||
};
|
||||
// The object each (name, key) is drawn as, and each name's objects in order of first use.
|
||||
let mut chosen: HashMap<(Name, Key), ObjRef> = HashMap::new();
|
||||
let mut targets: Vec<(Name, Vec<ObjRef>)> = Vec::new();
|
||||
for d in drawn {
|
||||
let k = key_of(d.ctm, d.width);
|
||||
if chosen.contains_key(&(d.name.clone(), k)) {
|
||||
continue;
|
||||
}
|
||||
let (to, count) = fix_form(doc, run, h, d.r, d.ctm, d.width, depth + 1);
|
||||
fixed += count;
|
||||
chosen.insert((d.name.clone(), k), to);
|
||||
match targets.iter_mut().find(|(name, _)| *name == d.name) {
|
||||
Some((_, list)) => {
|
||||
if !list.contains(&to) {
|
||||
list.push(to);
|
||||
}
|
||||
}
|
||||
None => targets.push((d.name, vec![to])),
|
||||
}
|
||||
}
|
||||
// The first object keeps the name; each other one gets a name of its own, and the `Do`s
|
||||
// drawing it are renamed.
|
||||
let mut repointed = xo;
|
||||
let mut changed = false;
|
||||
let mut renames = Renames::new();
|
||||
for (name, list) in &targets {
|
||||
for (i, to) in list.iter().enumerate() {
|
||||
let at = if i == 0 { name.clone() } else { fresh_name(&repointed, name) };
|
||||
if repointed.get(&at).and_then(Object::as_ref) != Some(*to) {
|
||||
repointed.set(at.clone(), Object::Ref(*to));
|
||||
changed = true;
|
||||
}
|
||||
if i > 0 {
|
||||
for ((was, k), t) in &chosen {
|
||||
if was == name && t == to {
|
||||
renames.insert((was.clone(), *k), at.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !renames.is_empty() {
|
||||
// The same rewrite again, with the renamed `Do`s.
|
||||
let mut state = start.clone();
|
||||
let mut again: Vec<Drawn> = Vec::new();
|
||||
new = data.iter().map(|d| rewrite(d, &mut state, h, resources, &*doc, &mut again, &renames).0).collect();
|
||||
}
|
||||
(new, fixed, changed.then_some(repointed))
|
||||
}
|
||||
|
||||
/// The object `r` is drawn as under `ctm` with line width `width` in effect, rewritten for
|
||||
/// them, and the strokes that rewrite fixed. It is `r` itself, rewritten in place, unless
|
||||
/// something else draws `r` differently; then it is a copy.
|
||||
fn fix_form(doc: &mut Document, run: &mut Run, h: &Hairlines, r: ObjRef, ctm: [f64; 6], width: f64, depth: usize) -> (ObjRef, usize) {
|
||||
let k = key_of(ctm, width);
|
||||
if let Some(&to) = run.done.get(&(r, k)) {
|
||||
return (to, 0);
|
||||
}
|
||||
if depth > MAX_DEPTH || run.budget == 0 {
|
||||
return (r, 0);
|
||||
}
|
||||
run.budget -= 1;
|
||||
// Marked before rewriting, so a form that draws itself ends.
|
||||
run.done.insert((r, k), r);
|
||||
let s = match run.original.get(&r) {
|
||||
Some(s) => s.clone(),
|
||||
None => {
|
||||
let Some(s) = stream_of(doc, r) else { return (r, 0) };
|
||||
run.original.insert(r, s.clone());
|
||||
s
|
||||
}
|
||||
};
|
||||
let Ok(data) = s.decoded() else { return (r, 0) };
|
||||
let matrix = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| {
|
||||
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
<[f64; 6]>::try_from(v).ok()
|
||||
});
|
||||
let base = match matrix {
|
||||
Some(m) => mul(m, ctm),
|
||||
None => ctm,
|
||||
};
|
||||
let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
|
||||
let (new, fixed, xo) = fix_context(doc, run, h, std::slice::from_ref(&data), &State::new(base, width), res.as_ref(), depth);
|
||||
let bytes = new.into_iter().next().flatten();
|
||||
let to = if bytes.is_none() && xo.is_none() {
|
||||
if run.used.get(&r) == Some(&true) {
|
||||
// `r` was rewritten in place for another use; this one draws it as it was.
|
||||
doc.add(Object::Stream(s))
|
||||
} else {
|
||||
run.used.insert(r, false);
|
||||
r
|
||||
}
|
||||
} else {
|
||||
let mut form = s;
|
||||
if let Some(xo) = xo {
|
||||
let mut res = res.unwrap_or_default();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
form.dict.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
}
|
||||
if let Some(bytes) = bytes {
|
||||
let mut dict = form.dict;
|
||||
for key in [&b"Length"[..], b"Filter", b"DecodeParms"] {
|
||||
dict.remove(key);
|
||||
}
|
||||
form = Stream::flate(dict, &bytes);
|
||||
}
|
||||
if run.shared.contains(&r) || run.used.contains_key(&r) {
|
||||
doc.add(Object::Stream(form))
|
||||
} else {
|
||||
doc.set(r, Object::Stream(form));
|
||||
run.used.insert(r, true);
|
||||
r
|
||||
}
|
||||
};
|
||||
run.done.insert((r, k), to);
|
||||
(to, fixed)
|
||||
}
|
||||
|
||||
/// Fix the hairlines on `pages` (0-based). Each page's content, and each Form XObject it draws
|
||||
/// (for the scale and line width it is drawn under), is rewritten where a stroke is thinner
|
||||
/// than `h.narrower_than` points on the page. A form drawn under more than one, or drawn by a
|
||||
/// page not in `pages` or by an annotation's appearance, is rewritten as a copy for the uses
|
||||
/// that need one, and the page's (or drawing form's) resources point at the copy; a resource
|
||||
/// dictionary other pages share is not changed. Type 3 glyphs and tiling patterns are rewritten
|
||||
/// in place, once, at a nominal scale (see `fix_in_resources`), and those other uses of them are
|
||||
/// counted in [`Fixed::elsewhere`].
|
||||
pub fn fix_hairlines(doc: &mut Document, pages: &[usize], h: &Hairlines) -> Result<Fixed, EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
if !h.in_range() {
|
||||
return Err(EditError::Invalid(format!("the hairline widths must be numbers of points, above 0 and at most {}", Hairlines::MAX_POINTS)));
|
||||
}
|
||||
let mut result = Fixed::default();
|
||||
let mut run =
|
||||
Run { done: HashMap::new(), shared: drawn_elsewhere(doc, pages), used: HashMap::new(), original: HashMap::new(), budget: MAX_FORMS };
|
||||
let mut done_glyphs: HashSet<ObjRef> = HashSet::new();
|
||||
for &i in pages {
|
||||
let Some(page) = page_list(doc).get(i).cloned() else { continue };
|
||||
let resources = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
let mut page_fixed = 0usize;
|
||||
// The page's streams, each rewritten on its own (state carries across them), and the
|
||||
// forms they draw. A page with a stream that cannot be read is left alone.
|
||||
if let Some(streams) = PageStreams::read(doc, &page.dict) {
|
||||
let start = State::new([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], 1.0);
|
||||
let (new, fixed, xo) = fix_context(doc, &mut run, h, &streams.data, &start, resources.as_ref(), 0);
|
||||
page_fixed += fixed;
|
||||
streams.write(doc, page.obj, new)?;
|
||||
if let Some(xo) = xo {
|
||||
// This page's own resources point at the copies; a resource dictionary other
|
||||
// pages share is not changed.
|
||||
let mut res = resources.clone().unwrap_or_default();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
}
|
||||
}
|
||||
if h.type3 || h.patterns {
|
||||
for res in resources_below(doc, resources.as_ref()) {
|
||||
page_fixed += fix_in_resources(doc, Some(&res), h, &mut done_glyphs, &run.shared, &mut result.elsewhere)?;
|
||||
}
|
||||
}
|
||||
if page_fixed > 0 {
|
||||
result.lines += page_fixed;
|
||||
result.pages += 1;
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// `resources` and the resources of every form they reach (a form that names none uses its
|
||||
/// parent's), each once.
|
||||
fn resources_below(doc: &Document, resources: Option<&Dict>) -> Vec<Dict> {
|
||||
let mut all: Vec<Dict> = resources.into_iter().cloned().collect();
|
||||
let mut seen: HashSet<ObjRef> = HashSet::new();
|
||||
let mut at = 0;
|
||||
while let Some(res) = all.get(at).cloned() {
|
||||
at += 1;
|
||||
let Some(xo) = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue };
|
||||
for (_, x) in xo.iter() {
|
||||
let Some(r) = x.as_ref() else { continue };
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
let Some(s) = stream_of(doc, r) else { continue };
|
||||
if s.dict.name(b"Subtype") == Some(b"Form")
|
||||
&& let Some(inner) = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())
|
||||
{
|
||||
all.push(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
/// Strokes inside Type 3 glyph procedures and tiling patterns of `resources`, on request. Their
|
||||
/// drawing scale is the font matrix or the pattern matrix (the text size and placement are
|
||||
/// not known here, so the glyph's own scale stands in). They are rewritten in place, once per
|
||||
/// run: a pattern's space is set by its matrix whatever CTM it is painted under, and a glyph
|
||||
/// is measured at a nominal size, so there is no per-use scale to make a copy for. The forms
|
||||
/// they draw are not followed. Each one changed that is in `shared` is added to `elsewhere`.
|
||||
fn fix_in_resources(
|
||||
doc: &mut Document,
|
||||
resources: Option<&Dict>,
|
||||
h: &Hairlines,
|
||||
done: &mut HashSet<ObjRef>,
|
||||
shared: &HashSet<ObjRef>,
|
||||
elsewhere: &mut usize,
|
||||
) -> Result<usize, EditError> {
|
||||
let Some(res) = resources else { return Ok(0) };
|
||||
let mut fixed = 0usize;
|
||||
let mut targets: Vec<(ObjRef, [f64; 6])> = Vec::new();
|
||||
if h.type3
|
||||
&& let Some(fonts) = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned())
|
||||
{
|
||||
for (_, f) in fonts.iter() {
|
||||
let fd = doc.resolve(f);
|
||||
let Some(fd) = fd.as_dict() else { continue };
|
||||
if fd.name(b"Subtype") != Some(b"Type3") {
|
||||
continue;
|
||||
}
|
||||
let fm = fd.get(b"FontMatrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| {
|
||||
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
<[f64; 6]>::try_from(v).ok()
|
||||
});
|
||||
// Glyph space → text space at a nominal 10 pt.
|
||||
let base = fm.map(|m| mul(m, [10.0, 0.0, 0.0, 10.0, 0.0, 0.0])).unwrap_or([0.01, 0.0, 0.0, 0.01, 0.0, 0.0]);
|
||||
if let Some(procs) = fd.get(b"CharProcs").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) {
|
||||
for (_, g) in procs.iter() {
|
||||
if let Some(r) = g.as_ref() {
|
||||
targets.push((r, base));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.patterns
|
||||
&& let Some(pats) = res.get(b"Pattern").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned())
|
||||
{
|
||||
for (_, p) in pats.iter() {
|
||||
let Some(r) = p.as_ref() else { continue };
|
||||
let Some(s) = stream_of(doc, r) else { continue };
|
||||
if s.dict.int(b"PatternType") != Some(1) {
|
||||
continue;
|
||||
}
|
||||
let pm = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| {
|
||||
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
<[f64; 6]>::try_from(v).ok()
|
||||
});
|
||||
targets.push((r, pm.unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])));
|
||||
}
|
||||
}
|
||||
let none = Renames::new();
|
||||
for (r, base) in targets {
|
||||
if !done.insert(r) {
|
||||
continue;
|
||||
}
|
||||
let Some(s) = stream_of(doc, r) else { continue };
|
||||
let Ok(data) = s.decoded() else { continue };
|
||||
let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
|
||||
let mut inner = Vec::new();
|
||||
let (new, n_fixed) = rewrite(&data, &mut State::new(base, 1.0), h, res.as_ref(), doc, &mut inner, &none);
|
||||
if let Some(bytes) = new {
|
||||
let mut dict = s.dict.clone();
|
||||
dict.remove(b"Length");
|
||||
dict.remove(b"Filter");
|
||||
dict.remove(b"DecodeParms");
|
||||
doc.set(r, Object::Stream(Stream::flate(dict, &bytes)));
|
||||
fixed += n_fixed;
|
||||
*elsewhere += usize::from(n_fixed > 0 && shared.contains(&r));
|
||||
}
|
||||
}
|
||||
let _ = n;
|
||||
Ok(fixed)
|
||||
}
|
||||
@@ -64,6 +64,9 @@ pub use text::{
|
||||
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
|
||||
clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
|
||||
};
|
||||
pub mod hairlines;
|
||||
mod page_streams;
|
||||
pub use hairlines::{Fixed as HairlinesFixed, Hairlines, fix_hairlines};
|
||||
pub mod added;
|
||||
pub use added::{
|
||||
Added, AddedImage, AddedText, Align, Content, Family, Ink, MarkerScan, add_content, delete_content, first_undrawable, isolate_content,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! A page's content streams rewritten one at a time: each stream keeps its own dictionary
|
||||
//! (and so its marks, such as `/PCMark`), and only the streams that changed are replaced.
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// The decoded content streams of one page, in drawing order.
|
||||
pub(crate) struct PageStreams {
|
||||
/// Each stream's object and dictionary.
|
||||
entries: Vec<(ObjRef, Dict)>,
|
||||
/// Each stream's decoded data, in the same order.
|
||||
pub(crate) data: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl PageStreams {
|
||||
/// The streams of `page_dict`'s `/Contents`. `None` when one of them is not a stream or
|
||||
/// cannot be decoded: rewriting the others would draw the page differently, so the page is
|
||||
/// left alone. A page without content has no streams.
|
||||
pub(crate) fn read(doc: &Document, page_dict: &Dict) -> Option<Self> {
|
||||
let refs: Vec<Object> = match page_dict.get(b"Contents") {
|
||||
None => Vec::new(),
|
||||
Some(c) => match &*doc.resolve(c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![c.clone()],
|
||||
},
|
||||
};
|
||||
let mut entries = Vec::with_capacity(refs.len());
|
||||
let mut data = Vec::with_capacity(refs.len());
|
||||
for e in &refs {
|
||||
let r = e.as_ref()?;
|
||||
let (dict, bytes) = match &*doc.get(r) {
|
||||
Object::Stream(s) => (s.dict.clone(), s.decoded().ok()?),
|
||||
_ => return None,
|
||||
};
|
||||
data.push(bytes);
|
||||
entries.push((r, dict));
|
||||
}
|
||||
Some(Self { entries, data })
|
||||
}
|
||||
|
||||
/// Put `new` in place of the streams that changed (`None` keeps a stream as it is). Each
|
||||
/// replacement is a new object with the old dictionary (minus its encoding), so a stream
|
||||
/// another page shares is not touched. Returns whether anything changed.
|
||||
pub(crate) fn write(self, doc: &mut Document, page: ObjRef, new: Vec<Option<Vec<u8>>>) -> Result<bool, EditError> {
|
||||
if new.iter().all(Option::is_none) {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut refs: Vec<Object> = Vec::with_capacity(self.entries.len());
|
||||
for (i, (r, dict)) in self.entries.into_iter().enumerate() {
|
||||
match new.get(i).and_then(Option::as_ref) {
|
||||
Some(bytes) => {
|
||||
let mut dict = dict;
|
||||
for k in [&b"Length"[..], b"Filter", b"DecodeParms", b"DL"] {
|
||||
dict.remove(k);
|
||||
}
|
||||
refs.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, bytes)))));
|
||||
}
|
||||
None => refs.push(Object::Ref(r)),
|
||||
}
|
||||
}
|
||||
let contents = match <[Object; 1]>::try_from(refs) {
|
||||
Ok([one]) => one,
|
||||
Err(many) => Object::Array(many),
|
||||
};
|
||||
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), contents))?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -9019,6 +9019,52 @@ fn removing_marks_strips_wrappers_that_closed_open_state() {
|
||||
assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]);
|
||||
}
|
||||
|
||||
/// Fix hairlines: zero-width strokes and strokes thinner than the threshold on
|
||||
/// the page (after the CTM) get the chosen width, in page content and the forms it draws; wider
|
||||
/// lines, and glyphs and patterns unless asked, are left alone.
|
||||
#[test]
|
||||
fn hairlines_are_widened_in_page_content_and_forms() {
|
||||
use crate::hairlines::{Hairlines, fix_hairlines};
|
||||
let mut doc = fixture();
|
||||
let page = page_list(&doc)[0].clone();
|
||||
// A form drawn at half scale with a 0.1 pt line (0.05 pt on the page) and a 4 pt line.
|
||||
let mut fd = Dict::new();
|
||||
fd.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
fd.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
fd.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, 100.0, 100.0].iter().map(|v| Object::Real(*v)).collect()));
|
||||
let form = doc.add(Object::Stream(Stream::flate(fd, b"0.1 w 0 0 m 50 50 l S 4 w 0 50 m 50 0 l S")));
|
||||
let content = b"0 w 10 10 m 100 10 l S 2 w 10 20 m 100 20 l S q 0.5 0 0 0.5 0 0 cm 0.2 w 0 0 m 10 10 l S /Fm1 Do Q 0.5 w 10 30 m 100 30 l S";
|
||||
let cs = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Fm1".to_vec(), Object::Ref(form));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
doc.update_dict(page.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Ref(cs));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
})
|
||||
.unwrap();
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
// Page: the 0 w line and the 0.2 w line at half scale (0.1 pt); the 2 pt and 0.5 pt lines stay.
|
||||
// Form: the 0.1 w line (0.05 pt on the page); the 4 pt line stays.
|
||||
assert_eq!(fixed, crate::hairlines::Fixed { lines: 3, pages: 1, elsewhere: 0 });
|
||||
let flat = |b: &[u8]| String::from_utf8_lossy(b).split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let text = flat(&page_content_bytes(&doc, 0));
|
||||
assert!(text.contains("0.72 w 10 10 m 100 10 l S"), "zero width → 0.72 pt: {text}");
|
||||
assert!(text.contains("2 w 10 20 m 100 20 l S") && text.contains("0.5 w 10 30 m 100 30 l S"), "wider lines untouched: {text}");
|
||||
assert!(text.contains("1.44 w 0 0 m 10 10 l S"), "under a half-scale CTM the width is doubled: {text}");
|
||||
let Object::Stream(fs) = &*doc.get(form) else { panic!() };
|
||||
let ft = flat(&fs.decoded().unwrap());
|
||||
assert!(ft.contains("1.44 w 0 0 m 50 50 l S") && ft.contains("4 w 0 50 m 50 0 l S"), "{ft}");
|
||||
// Nothing left to fix: a second run changes nothing.
|
||||
assert_eq!(fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap().lines, 0);
|
||||
// A stricter threshold catches the 0.5 pt line too, with a custom width.
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { narrower_than: 0.6, width: 1.0, ..Hairlines::default() }).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
assert!(flat(&page_content_bytes(&doc, 0)).contains("1 w 10 30 m 100 30 l S"));
|
||||
assert!(fix_hairlines(&mut doc, &[0], &Hairlines { width: 0.0, ..Hairlines::default() }).is_err());
|
||||
}
|
||||
|
||||
/// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup
|
||||
/// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark,
|
||||
/// TrapNet, Watermark), which it used to bake into the page and delete with their data.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Fix hairlines follows the line width an ExtGState's `/LW` sets through `gs`: a stroke it
|
||||
//! leaves thin is found, and the width put back after a widened stroke is that one.
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object};
|
||||
use pdfcraft_edit::{Hairlines, fix_hairlines};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when
|
||||
/// it is a stream); object 1 is the catalog.
|
||||
fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, (dict, data)) in objects.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
match data {
|
||||
Some(data) => {
|
||||
out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes());
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(b"\nendstream");
|
||||
}
|
||||
None => out.extend_from_slice(dict.as_bytes()),
|
||||
}
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
/// A stream's data, its tokens joined by single spaces.
|
||||
fn flat(doc: &Document, r: ObjRef) -> String {
|
||||
match &*doc.get(r) {
|
||||
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::<Vec<_>>().join(" "),
|
||||
_ => panic!("not a stream"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Page `i`'s content (a single stream), flattened.
|
||||
fn page_text(doc: &Document, i: usize) -> String {
|
||||
let page = pdfcraft_model::pages(doc).swap_remove(i);
|
||||
flat(doc, page.dict.get(b"Contents").and_then(Object::as_ref).unwrap())
|
||||
}
|
||||
|
||||
/// A one-page document drawing `content`, with `/GS0` setting `/LW lw` and `/GS1` no width.
|
||||
fn with_gstate(lw: &str, content: &[u8]) -> Document {
|
||||
let page = format!(
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /ExtGState << /GS0 << /Type /ExtGState /LW {lw} >> /GS1 << /Type /ExtGState /CA 1 >> >> >> >>"
|
||||
);
|
||||
pdf(&[
|
||||
("<< /Type /Catalog /Pages 2 0 R >>", None),
|
||||
("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None),
|
||||
(page.as_str(), None),
|
||||
("", Some(content)),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_width_put_back_is_the_one_gs_set() {
|
||||
// 2 pt under a tenth scale is 0.2 pt on the page: both strokes are widened, and each gets
|
||||
// the 2 pt back after it.
|
||||
let mut doc = with_gstate("2", b"/GS0 gs 0.1 0 0 0.1 0 0 cm 0 0 m 1 0 l S 0 1 m 1 1 l S");
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 2);
|
||||
let t = page_text(&doc, 0);
|
||||
assert_eq!(t.matches("l S 2 w").count(), 2, "{t}");
|
||||
assert!(!t.contains(" 1 w"), "no stroke is put back to the default width: {t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_width_set_by_gs_is_a_hairline() {
|
||||
let mut doc = with_gstate("0", b"/GS0 gs 0 0 m 1 0 l S");
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
assert_eq!(page_text(&doc, 0), "/GS0 gs 0.72 w 0 0 m 1 0 l S 0 w");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gs_without_a_width_keeps_the_one_in_effect() {
|
||||
let mut doc = with_gstate("2", b"0.1 w /GS1 gs 0 0 m 1 0 l S");
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
assert_eq!(page_text(&doc, 0), "0.1 w /GS1 gs 0.72 w 0 0 m 1 0 l S 0.1 w");
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Fix hairlines measures a form's strokes with the line width in effect where the form is
|
||||
//! drawn (a form inherits the graphics state of its `Do`), and puts that width back after a
|
||||
//! stroke it widens, in forms drawn by forms too.
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object};
|
||||
use pdfcraft_edit::{Hairlines, fix_hairlines};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when
|
||||
/// it is a stream); object 1 is the catalog.
|
||||
fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, (dict, data)) in objects.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
match data {
|
||||
Some(data) => {
|
||||
out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes());
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(b"\nendstream");
|
||||
}
|
||||
None => out.extend_from_slice(dict.as_bytes()),
|
||||
}
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
/// A stream's data, its tokens joined by single spaces.
|
||||
fn flat(doc: &Document, r: ObjRef) -> String {
|
||||
match &*doc.get(r) {
|
||||
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::<Vec<_>>().join(" "),
|
||||
_ => panic!("not a stream"),
|
||||
}
|
||||
}
|
||||
|
||||
const CATALOG: (&str, Option<&[u8]>) = ("<< /Type /Catalog /Pages 2 0 R >>", None);
|
||||
const PAGES: (&str, Option<&[u8]>) = ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None);
|
||||
const PAGE: (&str, Option<&[u8]>) = ("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None);
|
||||
const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]";
|
||||
|
||||
#[test]
|
||||
fn a_form_without_its_own_width_is_measured_with_the_width_at_its_do() {
|
||||
// 0.1 pt is in effect where the form is drawn; the form sets no width of its own.
|
||||
let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.1 w /Fm0 Do")), (FORM, Some(b"0 0 m 10 0 l S"))]);
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
assert_eq!(flat(&doc, ObjRef { num: 5, generation: 0 }), "0.72 w 0 0 m 10 0 l S 0.1 w");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_widened_stroke_in_a_form_gets_the_inherited_width_back() {
|
||||
// 0.4 pt at half scale is 0.2 pt on the page; after the stroke the form's later strokes
|
||||
// must draw with the 0.4 they inherited, not the 1 a fresh graphics state has.
|
||||
let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.5 0 0 0.5 0 0 cm 0.4 w /Fm0 Do")), (FORM, Some(b"0 0 m 1 0 l S"))]);
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
assert_eq!(flat(&doc, ObjRef { num: 5, generation: 0 }), "1.44 w 0 0 m 1 0 l S 0.4 w");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_drawn_by_a_form_inherits_the_width_too() {
|
||||
let fm0 = "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /XObject << /Fm1 6 0 R >> >>";
|
||||
let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.1 w /Fm0 Do")), (fm0, Some(b"/Fm1 Do")), (FORM, Some(b"0 0 m 10 0 l S"))]);
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
assert_eq!(flat(&doc, ObjRef { num: 6, generation: 0 }), "0.72 w 0 0 m 10 0 l S 0.1 w");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Fix hairlines gives a thin stroke a width of its own: set before its path begins (a `w`
|
||||
//! inside a path is not allowed) and put back after it is painted, so the `w` other strokes
|
||||
//! share, and strokes under another transformation, keep their width.
|
||||
|
||||
use pdfcraft_cos::{Document, Object};
|
||||
use pdfcraft_edit::{Hairlines, fix_hairlines};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A one-page document drawing `content`.
|
||||
fn one_page(content: &[u8]) -> Document {
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(),
|
||||
];
|
||||
let mut s = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
|
||||
s.extend_from_slice(content);
|
||||
s.extend_from_slice(b"\nendstream");
|
||||
objs.push(s);
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
/// The page's content, its tokens joined by single spaces.
|
||||
fn content_text(doc: &Document) -> String {
|
||||
let page = pdfcraft_model::pages(doc).swap_remove(0);
|
||||
let list: Vec<Object> = match &*doc.resolve(page.dict.get(b"Contents").unwrap()) {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![page.dict.get(b"Contents").unwrap().clone()],
|
||||
};
|
||||
let mut text = String::new();
|
||||
for e in list {
|
||||
if let Object::Stream(s) = &*doc.resolve(&e) {
|
||||
text.push_str(&String::from_utf8_lossy(&s.decoded().unwrap()));
|
||||
text.push(' ');
|
||||
}
|
||||
}
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thin_stroke_does_not_change_the_width_other_strokes_share() {
|
||||
// The first line is 0.1 pt on the page; the second, under a tenfold scale, is 1 pt.
|
||||
let mut doc = one_page(b"0.1 w 0 0 m 10 0 l S 10 0 0 10 0 0 cm 0 1 m 1 1 l S");
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
let t = content_text(&doc);
|
||||
assert!(t.starts_with("0.1 w 0.72 w 0 0 m 10 0 l S 0.1 w 10 0 0 10 0 0 cm"), "the shared w is kept: {t}");
|
||||
assert!(!t.contains("7.2"), "the scaled stroke keeps its 1 pt: {t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_inside_q_does_not_change_the_outer_width() {
|
||||
let mut doc = one_page(b"0.1 w q 0 0 m 1 0 l S Q 0 1 m 1 1 l S");
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 2);
|
||||
let t = content_text(&doc);
|
||||
assert!(t.starts_with("0.1 w q 0.72 w 0 0 m 1 0 l S 0.1 w Q 0.72 w 0 1 m 1 1 l S 0.1 w"), "{t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_width_is_set_before_the_path_not_inside_it() {
|
||||
// No `w` before the stroke: the default 1 pt at half scale is 0.5 pt on the page.
|
||||
let mut doc = one_page(b"0.5 0 0 0.5 0 0 cm 0 0 m 10 0 l S 5 5 10 10 re f");
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { narrower_than: 0.6, width: 1.0, ..Hairlines::default() }).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
let t = content_text(&doc);
|
||||
assert!(t.contains("cm 2 w 0 0 m 10 0 l S 1 w 5 5 10 10 re f"), "{t}");
|
||||
assert!(!t.contains("l 2 w S"), "no w inside the path: {t}");
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Fix hairlines rewrites a form for the scale it is drawn under: a form a page not being
|
||||
//! fixed also draws, or one drawn under two scales, is rewritten as a copy for the use that
|
||||
//! needs it, so the other uses keep drawing it as it was.
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object};
|
||||
use pdfcraft_edit::{Hairlines, fix_hairlines};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when
|
||||
/// it is a stream); object 1 is the catalog.
|
||||
fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, (dict, data)) in objects.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
match data {
|
||||
Some(data) => {
|
||||
out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes());
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(b"\nendstream");
|
||||
}
|
||||
None => out.extend_from_slice(dict.as_bytes()),
|
||||
}
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
/// A stream's data, its tokens joined by single spaces.
|
||||
fn flat(doc: &Document, r: ObjRef) -> String {
|
||||
match &*doc.get(r) {
|
||||
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::<Vec<_>>().join(" "),
|
||||
_ => panic!("not a stream"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Page `i`'s content (a single stream), flattened.
|
||||
fn page_text(doc: &Document, i: usize) -> String {
|
||||
let page = pdfcraft_model::pages(doc).swap_remove(i);
|
||||
flat(doc, page.dict.get(b"Contents").and_then(Object::as_ref).unwrap())
|
||||
}
|
||||
|
||||
/// The object page `i`'s resources draw as `name`.
|
||||
fn xobject(doc: &Document, i: usize, name: &[u8]) -> ObjRef {
|
||||
let page = pdfcraft_model::pages(doc).swap_remove(i);
|
||||
let res = doc.resolve(page.dict.get(b"Resources").unwrap());
|
||||
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
|
||||
xo.as_dict().unwrap().get(name).and_then(Object::as_ref).unwrap()
|
||||
}
|
||||
|
||||
const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]";
|
||||
const ORIGINAL: &str = "0.4 w 0 0 m 1 0 l S";
|
||||
|
||||
#[test]
|
||||
fn a_form_another_page_draws_is_fixed_as_a_copy() {
|
||||
// Both pages share one resource dictionary; the first draws the form at half scale (its
|
||||
// 0.4 pt line is 0.2 pt there), the second at full size.
|
||||
let mut doc = pdf(&[
|
||||
("<< /Type /Catalog /Pages 2 0 R >>", None),
|
||||
("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>", None),
|
||||
("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 7 0 R >>", None),
|
||||
("<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources 7 0 R >>", None),
|
||||
("", Some(b"0.5 0 0 0.5 0 0 cm /Fm0 Do")),
|
||||
("", Some(b"/Fm0 Do")),
|
||||
("<< /XObject << /Fm0 8 0 R >> >>", None),
|
||||
(FORM, Some(ORIGINAL.as_bytes())),
|
||||
]);
|
||||
let form = ObjRef { num: 8, generation: 0 };
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
assert_eq!(flat(&doc, form), ORIGINAL, "the form the second page draws is unchanged");
|
||||
assert_eq!(xobject(&doc, 1, b"Fm0"), form, "the second page still draws it");
|
||||
let copy = xobject(&doc, 0, b"Fm0");
|
||||
assert_ne!(copy, form, "the first page draws a copy");
|
||||
assert_eq!(flat(&doc, copy), "0.4 w 1.44 w 0 0 m 1 0 l S 0.4 w");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_drawn_under_two_scales_gets_one_object_for_each() {
|
||||
let mut doc = pdf(&[
|
||||
("<< /Type /Catalog /Pages 2 0 R >>", None),
|
||||
("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None),
|
||||
("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None),
|
||||
("", Some(b"q 0.5 0 0 0.5 0 0 cm /Fm0 Do Q /Fm0 Do")),
|
||||
(FORM, Some(ORIGINAL.as_bytes())),
|
||||
]);
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
let text = page_text(&doc, 0);
|
||||
let words: Vec<&str> = text.split(' ').collect();
|
||||
let drawn: Vec<&str> = words.windows(2).filter(|w| w[1] == "Do").map(|w| w[0]).collect();
|
||||
assert_eq!(drawn.len(), 2, "{text}");
|
||||
assert_eq!(drawn[0], "/Fm0", "{text}");
|
||||
assert_ne!(drawn[1], "/Fm0", "the full-size use draws another name: {text}");
|
||||
let half = xobject(&doc, 0, b"Fm0");
|
||||
let full = xobject(&doc, 0, drawn[1].trim_start_matches('/').as_bytes());
|
||||
assert_ne!(half, full);
|
||||
assert_eq!(flat(&doc, half), "0.4 w 1.44 w 0 0 m 1 0 l S 0.4 w");
|
||||
assert_eq!(flat(&doc, full), ORIGINAL, "at full size the 0.4 pt line is left as it was");
|
||||
}
|
||||
|
||||
const SHADED: &str = "/Pattern cs /P0 scn 0 0 10 10 re f";
|
||||
const PATTERN: &str = "/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 9 9] /XStep 9 /YStep 9 /Resources << >>";
|
||||
const THIN: &str = "0 w 0 0 m 9 9 l S";
|
||||
|
||||
#[test]
|
||||
fn a_pattern_another_page_uses_is_reported() {
|
||||
let mut doc = pdf(&[
|
||||
("<< /Type /Catalog /Pages 2 0 R >>", None),
|
||||
("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>", None),
|
||||
("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 6 0 R >>", None),
|
||||
("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 6 0 R >>", None),
|
||||
("", Some(SHADED.as_bytes())),
|
||||
("<< /Pattern << /P0 7 0 R >> >>", None),
|
||||
(PATTERN, Some(THIN.as_bytes())),
|
||||
]);
|
||||
let h = Hairlines { patterns: true, ..Hairlines::default() };
|
||||
let mut both = doc.clone();
|
||||
assert_eq!(fix_hairlines(&mut both, &[0, 1], &h).unwrap().elsewhere, 0, "every use is in the run");
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &h).unwrap();
|
||||
assert_eq!((fixed.lines, fixed.elsewhere), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_a_form_uses_is_fixed_too() {
|
||||
let mut doc = pdf(&[
|
||||
("<< /Type /Catalog /Pages 2 0 R >>", None),
|
||||
("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None),
|
||||
("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None),
|
||||
("", Some(b"/Fm0 Do")),
|
||||
// The form lists itself: walking the resources ends all the same.
|
||||
(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /Pattern << /P0 6 0 R >> /XObject << /Fm0 5 0 R >> >>",
|
||||
Some(SHADED.as_bytes()),
|
||||
),
|
||||
(PATTERN, Some(THIN.as_bytes())),
|
||||
]);
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { patterns: true, ..Hairlines::default() }).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
assert_ne!(flat(&doc, ObjRef { num: 6, generation: 0 }), THIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widths_out_of_range_are_refused() {
|
||||
let mut doc = pdf(&[
|
||||
("<< /Type /Catalog /Pages 2 0 R >>", None),
|
||||
("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None),
|
||||
("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", None),
|
||||
("", Some(THIN.as_bytes())),
|
||||
]);
|
||||
for (narrower_than, width) in [(0.2, -1.0), (0.2, f64::NAN), (0.2, f64::INFINITY), (0.2, 10_000.0), (-1.0, 0.7), (f64::NAN, 0.7), (10_000.0, 0.7)]
|
||||
{
|
||||
let h = Hairlines { narrower_than, width, ..Hairlines::default() };
|
||||
assert!(fix_hairlines(&mut doc, &[0], &h).is_err(), "{narrower_than} {width}");
|
||||
}
|
||||
let most = Hairlines { narrower_than: Hairlines::MAX_POINTS, width: Hairlines::MAX_POINTS, ..Hairlines::default() };
|
||||
assert_eq!(fix_hairlines(&mut doc, &[0], &most).unwrap().lines, 1);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Fix hairlines rewrites each of a page's content streams on its own: a stream keeps its
|
||||
//! dictionary (its `/PCMark` among it), streams that did not change stay as they are, the
|
||||
//! graphics state carries from one stream to the next, and a page with a stream that cannot
|
||||
//! be read is left alone instead of losing that stream.
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object};
|
||||
use pdfcraft_edit::{Hairlines, fix_hairlines};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A one-page document whose `/Contents` lists `streams` (dictionary entries, data).
|
||||
fn one_page(streams: &[(&str, &[u8])]) -> Document {
|
||||
let refs: Vec<String> = (0..streams.len()).map(|i| format!("{} 0 R", i + 4)).collect();
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".to_vec(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Contents [{}] >>", refs.join(" ")).into_bytes(),
|
||||
];
|
||||
for (dict, data) in streams {
|
||||
let mut s = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
|
||||
s.extend_from_slice(data);
|
||||
s.extend_from_slice(b"\nendstream");
|
||||
objs.push(s);
|
||||
}
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
/// The page's `/Contents` references.
|
||||
fn contents(doc: &Document) -> Vec<ObjRef> {
|
||||
let page = pdfcraft_model::pages(doc).swap_remove(0);
|
||||
match &*doc.resolve(page.dict.get(b"Contents").unwrap()) {
|
||||
Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(),
|
||||
_ => page.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(doc: &Document, r: ObjRef) -> String {
|
||||
match &*doc.get(r) {
|
||||
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::<Vec<_>>().join(" "),
|
||||
_ => panic!("not a stream"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_stream_keeps_its_dictionary_and_only_changed_ones_are_replaced() {
|
||||
let mut doc = one_page(&[("", b"2 w 0 0 m 1 1 l S"), ("/PCMark /Redaction", b"0 w 0 0 m 1 1 l S")]);
|
||||
let before = contents(&doc);
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
let after = contents(&doc);
|
||||
assert_eq!(after.len(), 2, "still two streams");
|
||||
assert_eq!(after[0], before[0], "the unchanged stream is the same object");
|
||||
assert_ne!(after[1], before[1]);
|
||||
let mark = match &*doc.get(after[1]) {
|
||||
Object::Stream(s) => s.dict.name(b"PCMark").map(<[u8]>::to_vec),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(mark.as_deref(), Some(&b"Redaction"[..]), "the mark is kept");
|
||||
assert!(text_of(&doc, after[1]).contains("0.72 w 0 0 m 1 1 l S"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_graphics_state_carries_from_one_stream_to_the_next() {
|
||||
// 0.3 pt under the first stream's half scale is 0.15 pt on the page.
|
||||
let mut doc = one_page(&[("", b"0.5 0 0 0.5 0 0 cm"), ("", b"0.3 w 0 0 m 1 1 l S")]);
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 1);
|
||||
let after = contents(&doc);
|
||||
assert!(text_of(&doc, after[1]).contains("1.44 w 0 0 m 1 1 l S"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_with_an_unreadable_stream_is_left_alone() {
|
||||
let mut doc = one_page(&[("", b"0 w 0 0 m 1 1 l S"), ("/Filter /DCTDecode", b"not a picture")]);
|
||||
let before = contents(&doc);
|
||||
let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap();
|
||||
assert_eq!(fixed.lines, 0);
|
||||
assert_eq!(contents(&doc), before, "no stream is dropped");
|
||||
}
|
||||
@@ -412,7 +412,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")),
|
||||
item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready),
|
||||
item("Add printer marks", "ruler", "prepress.marks", Planned("M11")),
|
||||
item("Fix hairlines", "minus", "prepress.hairlines", Planned("M11")),
|
||||
item("Fix hairlines", "minus", "prepress.hairlines", Ready),
|
||||
],
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -403,6 +403,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"),
|
||||
c("page.split", "Split document…", PAGES, None, Assembly, "scissors"),
|
||||
c("page.number", "Number pages…", PAGES, None, Assembly, "hash"),
|
||||
c("prepress.hairlines", "Fix hairlines…", None, None, Modification, "minus"),
|
||||
c("page.properties", "Page properties…", PAGES, None, Modification, "info"),
|
||||
c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"),
|
||||
c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"),
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit,
|
||||
InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox,
|
||||
Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image,
|
||||
field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted,
|
||||
xfa, xfa_after_data_change,
|
||||
AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, Hairlines, HeaderFooter, Hidden,
|
||||
ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget,
|
||||
PageBox, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file,
|
||||
embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa,
|
||||
unrestricted, xfa, xfa_after_data_change,
|
||||
};
|
||||
|
||||
mod labels;
|
||||
@@ -349,6 +349,12 @@ pub enum Edit {
|
||||
},
|
||||
/// Document Properties ▸ Initial View (and the language and binding).
|
||||
SetInitialView(Box<InitialView>),
|
||||
/// Print production ▸ Fix hairlines: strokes narrower than `settings.narrower_than` points on
|
||||
/// the page (after every transformation; zero-width lines always) get `settings.width`.
|
||||
FixHairlines {
|
||||
pages: Vec<usize>,
|
||||
settings: Hairlines,
|
||||
},
|
||||
/// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under
|
||||
/// `name`, made unique; `mime` is the embedded file's `/Subtype` when known.
|
||||
AddAttachment {
|
||||
|
||||
@@ -250,6 +250,7 @@ impl Edit {
|
||||
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
|
||||
Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"),
|
||||
Edit::SetInitialView(_) => Label::of("Change initial view"),
|
||||
Edit::FixHairlines { .. } => Label::of("Fix hairlines"),
|
||||
Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()),
|
||||
Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()),
|
||||
Edit::AutoTagText => Label::of("Automatically tag text"),
|
||||
|
||||
@@ -134,6 +134,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::MoveInTabOrder { .. }
|
||||
| Edit::SetCalculationOrder { .. }
|
||||
| Edit::SetInitialView(_)
|
||||
| Edit::FixHairlines { .. }
|
||||
| Edit::AddAttachment { .. }
|
||||
| Edit::DeleteAttachment { .. }
|
||||
| Edit::AutoTagText
|
||||
@@ -598,6 +599,12 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::EraseInk { page, index, path, radius } => {
|
||||
pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?;
|
||||
}
|
||||
Edit::FixHairlines { pages, settings } => {
|
||||
let fixed = pdfcraft_edit::fix_hairlines(doc, pages, settings)?;
|
||||
if fixed.lines == 0 {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no hairlines found on those pages".into())));
|
||||
}
|
||||
}
|
||||
Edit::AddAttachment { name, bytes, description, mime } => {
|
||||
pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::AddWatermark { .. }
|
||||
| Edit::AddBackground { .. }
|
||||
| Edit::RemoveMarks { .. }
|
||||
| Edit::FixHairlines { .. }
|
||||
// Recognized text is an invisible text layer stamped on its page, with its font in the
|
||||
// page's resources (#6310): a full re-inspection made applying one word cost seconds on
|
||||
// a large file.
|
||||
|
||||
@@ -95,8 +95,8 @@ pub use pdfcraft_edit::added::{
|
||||
};
|
||||
pub use pdfcraft_edit::paragraph_align;
|
||||
pub use pdfcraft_edit::{
|
||||
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, Ink as TextInk,
|
||||
MarkKind, Watermark,
|
||||
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, Hairlines, HairlinesFixed,
|
||||
HeaderFooter, Ink as TextInk, MarkKind, Watermark,
|
||||
};
|
||||
pub use pdfcraft_forms::{
|
||||
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
|
||||
|
||||
@@ -10,12 +10,13 @@ use crate::check_refresh_panic;
|
||||
use crate::edit::{EditCtx, check_permission, run_edit};
|
||||
use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts};
|
||||
use crate::{
|
||||
BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution,
|
||||
LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, StatusCaptions, SummarySort,
|
||||
TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, comment_summary, decode_text,
|
||||
display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows,
|
||||
parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of,
|
||||
source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets,
|
||||
BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hairlines, HairlinesFixed, Hidden,
|
||||
ImageResolution, LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart,
|
||||
StatusCaptions, SummarySort, TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list,
|
||||
comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source,
|
||||
open_source_with, optimize, outline_rows, parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours,
|
||||
set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa,
|
||||
xfa_sync_datasets,
|
||||
};
|
||||
|
||||
mod editing;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions,
|
||||
Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_layer_states, print_raster,
|
||||
set_default_layer_state, source_kind, writable, write_full,
|
||||
Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hairlines, HairlinesFixed, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI,
|
||||
OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print,
|
||||
print_layer_states, print_raster, set_default_layer_state, source_kind, writable, write_full,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
@@ -114,6 +114,15 @@ impl Session {
|
||||
.map_err(EditError::Write)
|
||||
}
|
||||
|
||||
/// How many hairlines [`Edit::FixHairlines`] would change on `pages` of `id`, and on how many
|
||||
/// pages, without changing the document (the edit itself refuses when there is nothing to fix).
|
||||
pub fn count_hairlines(&self, id: DocId, pages: &[usize], settings: &Hairlines) -> HairlinesFixed {
|
||||
let Some(doc) = self.get(id) else { return HairlinesFixed::default() };
|
||||
let Some(editor) = doc.editor.as_ref() else { return HairlinesFixed::default() };
|
||||
let mut cos = editor.cos.clone();
|
||||
pdfcraft_edit::fix_hairlines(&mut cos, pages, settings).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// XFDF with these comments (0-based page, position in the page's `/Annots`) and their
|
||||
/// replies: what Copy puts on the clipboard; [`Edit::PasteComments`] takes it back in.
|
||||
pub fn export_comments(&self, id: DocId, which: &[(usize, usize)]) -> Result<String, EditError> {
|
||||
|
||||
@@ -328,6 +328,7 @@ impl PdfCraftApp {
|
||||
replace_draft: None,
|
||||
number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 },
|
||||
word_count_draft: word_count_ui::Draft::default(),
|
||||
hairlines_draft: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,12 @@ impl PdfCraftApp {
|
||||
self.execute("page.number");
|
||||
Some(Dialog::NumberPages)
|
||||
}
|
||||
"fix-hairlines" => {
|
||||
// Through the command, so it is refused with no document or none that
|
||||
// can be modified, as the menu is.
|
||||
self.execute("prepress.hairlines");
|
||||
return Ok(());
|
||||
}
|
||||
"page-properties" => {
|
||||
// Same path as the panel, so the selected pages and their order are seeded.
|
||||
self.execute("page.properties");
|
||||
|
||||
@@ -218,6 +218,7 @@ impl PdfCraftApp {
|
||||
self.protect_draft = Default::default();
|
||||
self.dialog = Some(Dialog::Protect);
|
||||
}
|
||||
"prepress.hairlines" => self.dialog = Some(Dialog::FixHairlines),
|
||||
"protect.remove" => {
|
||||
if self.apply_edit(Edit::RemoveProtection) {
|
||||
self.notify_tr("Security will be removed when you save");
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(super) mod appearance;
|
||||
pub mod bookmark_styles_ui;
|
||||
pub(super) mod comment_props;
|
||||
pub(super) mod credits;
|
||||
pub(super) mod hairlines_ui;
|
||||
pub(super) mod marks_ui;
|
||||
pub(super) mod pageboxes;
|
||||
pub(super) mod palette;
|
||||
@@ -43,6 +44,7 @@ struct Pending {
|
||||
link_command: Option<&'static str>,
|
||||
protect_now: bool,
|
||||
boxes_now: bool,
|
||||
fix_hairlines_now: bool,
|
||||
marks_now: bool,
|
||||
export_now: bool,
|
||||
props_now: bool,
|
||||
@@ -446,6 +448,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m
|
||||
req.close = req.apply || cancel;
|
||||
return false;
|
||||
}
|
||||
Dialog::FixHairlines => {
|
||||
let (go, cancel) = crate::hairlines_ui::body(ui, app, &t);
|
||||
req.fix_hairlines_now = go;
|
||||
req.close = go || cancel;
|
||||
return false;
|
||||
}
|
||||
Dialog::BookmarksFromTextStyles => {
|
||||
req.close = crate::bookmark_styles_ui::body(ui, app, &t);
|
||||
return false;
|
||||
@@ -1977,6 +1985,9 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
|
||||
app.protect_draft = Default::default();
|
||||
app.notify_tr("Password protection will be applied when you save");
|
||||
}
|
||||
if req.fix_hairlines_now {
|
||||
app.fix_hairlines();
|
||||
}
|
||||
if req.apply_number
|
||||
&& let Some(edit) = req.number_now.take()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Print production ▸ Fix hairlines: lines narrower than a width on the page become a chosen
|
||||
//! width; Type 3 glyphs and pattern strokes on request; the pages. Fix says how many strokes
|
||||
//! changed, and refuses nothing: a document with no hairlines is told so.
|
||||
|
||||
use pdfcraft_engine::{Edit, Hairlines};
|
||||
|
||||
use crate::pageboxes::Range;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::units::Unit;
|
||||
use crate::{PdfCraftApp, widgets};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HairlinesDraft {
|
||||
/// Both widths in points (shown in the preferences' unit).
|
||||
pub settings: Hairlines,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
impl Default for HairlinesDraft {
|
||||
fn default() -> Self {
|
||||
Self { settings: Hairlines::default(), range: Range::All }
|
||||
}
|
||||
}
|
||||
|
||||
impl HairlinesDraft {
|
||||
pub fn pages(&self, current: usize, count: usize) -> Vec<usize> {
|
||||
match self.range {
|
||||
Range::All => (0..count).collect(),
|
||||
Range::Current => vec![current.min(count.saturating_sub(1))],
|
||||
Range::Pages(a, b) => (a.max(1) - 1..b.min(count)).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A width in points edited in `unit`, named by the label `label`.
|
||||
fn width_field(ui: &mut egui::Ui, pts: &mut f64, unit: Unit, label: egui::Id) {
|
||||
let k = unit.per_point();
|
||||
let mut v = *pts * k;
|
||||
let step = 0.001 * k;
|
||||
if ui
|
||||
.add(egui::DragValue::new(&mut v).range(0.0..=Hairlines::MAX_POINTS * k).speed(step.max(0.0005)).fixed_decimals(unit.decimals().max(3)))
|
||||
.labelled_by(label)
|
||||
.changed()
|
||||
{
|
||||
*pts = v / k;
|
||||
}
|
||||
ui.label(unit.label());
|
||||
}
|
||||
|
||||
/// The dialog: (fix, cancel).
|
||||
pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) {
|
||||
let Some((i, id)) = app.active_ids() else { return (false, true) };
|
||||
let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
|
||||
let current = app.views[i].current;
|
||||
let unit = app.units;
|
||||
let d = &mut app.hairlines_draft;
|
||||
ui.label(egui::RichText::new(tl!("Fix hairlines")).font(theme::semibold(18.0)));
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("hairlines").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
let label = ui.label(tl!("Narrower than")).id;
|
||||
ui.horizontal(|ui| width_field(ui, &mut d.settings.narrower_than, unit, label));
|
||||
ui.end_row();
|
||||
let label = ui.label(tl!("New width")).id;
|
||||
ui.horizontal(|ui| width_field(ui, &mut d.settings.width, unit, label));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Zero-width lines always count as narrower.")).small().color(t.text_muted));
|
||||
widgets::section_title(ui, tl!("Also"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(widgets::checkbox(&mut d.settings.type3, tl!("Type 3 font glyphs")));
|
||||
ui.add(widgets::checkbox(&mut d.settings.patterns, tl!("Pattern strokes")));
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Lines drawn inside Type 3 glyphs and tiling patterns; off by default.")).small().color(t.text_muted));
|
||||
widgets::section_title(ui, tl!("Pages"));
|
||||
ui.horizontal(|ui| {
|
||||
widgets::radio_value(ui, &mut d.range, Range::All, tl!("All"));
|
||||
widgets::radio_value(ui, &mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())]));
|
||||
let (mut a, mut b) = match d.range {
|
||||
Range::Pages(a, b) => (a, b),
|
||||
_ => (1, count.max(1)),
|
||||
};
|
||||
let on = matches!(d.range, Range::Pages(..));
|
||||
if ui.add(widgets::radio(on, tl!("From"))).clicked() {
|
||||
d.range = Range::Pages(a, b);
|
||||
}
|
||||
let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count.max(1)));
|
||||
ui.label(tl!("to"));
|
||||
let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count.max(1)));
|
||||
if ra.changed() || rb.changed() {
|
||||
d.range = Range::Pages(a.min(b), b.max(a));
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
let (mut go, mut cancel) = (false, false);
|
||||
let ready = count > 0 && d.settings.width > 0.0;
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("Fix"), true)).inner.clicked() {
|
||||
go = true;
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
(go, cancel)
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Fix: count first (the edit refuses when there is nothing to fix), then apply and say.
|
||||
pub(crate) fn fix_hairlines(&mut self) {
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
let count = self.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
|
||||
let pages = self.hairlines_draft.pages(self.views[i].current, count);
|
||||
let settings = self.hairlines_draft.settings.clone();
|
||||
let found = self.session.count_hairlines(id, &pages, &settings);
|
||||
if found.lines == 0 {
|
||||
self.notify_tr("No hairlines found on those pages");
|
||||
return;
|
||||
}
|
||||
if self.apply_edit(Edit::FixHairlines { pages, settings }) {
|
||||
let (n, p) = (found.lines.to_string(), found.pages.to_string());
|
||||
match (found.lines, found.pages) {
|
||||
// One stroke can only be on one page.
|
||||
(1, _) => self.notify_tr("Fixed 1 hairline on 1 page"),
|
||||
(_, 1) => self.notify_fmt("Fixed {n} hairlines on 1 page", &[("n", &n)]),
|
||||
_ => self.notify_fmt("Fixed {n} hairlines on {p} pages", &[("n", &n), ("p", &p)]),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ use dialogs::appearance;
|
||||
pub use dialogs::bookmark_styles_ui;
|
||||
use dialogs::comment_props;
|
||||
use dialogs::credits;
|
||||
use dialogs::hairlines_ui;
|
||||
use dialogs::marks_ui;
|
||||
use dialogs::pageboxes;
|
||||
use dialogs::palette;
|
||||
@@ -708,6 +709,7 @@ pub struct PdfCraftApp {
|
||||
/// Number pages dialog settings (1-based pages).
|
||||
pub number_draft: NumberDraft,
|
||||
pub(crate) word_count_draft: word_count_ui::Draft,
|
||||
pub hairlines_draft: hairlines_ui::HairlinesDraft,
|
||||
/// Protect Using Password dialog state.
|
||||
pub protect_draft: protect::ProtectDraft,
|
||||
/// New bookmarks from text styles dialog state (#1693).
|
||||
|
||||
@@ -109,6 +109,8 @@ pub enum Dialog {
|
||||
Split,
|
||||
/// Pages ▸ Number pages… (page labels).
|
||||
NumberPages,
|
||||
/// Print production ▸ Fix hairlines.
|
||||
FixHairlines,
|
||||
/// Organize pages ▸ Page properties (the tab order of the selected pages).
|
||||
PageProps,
|
||||
/// Protect Using Password.
|
||||
|
||||
@@ -9428,6 +9428,75 @@ fn test_cropped_image_selection_and_bounding_box() {
|
||||
assert!(h.state().views[0].picked_image.is_none(), "clicking cropped-away region must not hit-test or select the image");
|
||||
}
|
||||
|
||||
/// Print production ▸ Fix hairlines: the dialog widens zero-width and too-thin
|
||||
/// strokes and says how many; with nothing to fix it says so and changes nothing.
|
||||
#[test]
|
||||
fn fix_hairlines_from_the_dialog() {
|
||||
// A page with a zero-width line and a 2 pt line.
|
||||
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||||
let page = pdfcraft_model::pages(&doc).remove(0);
|
||||
let cs = doc
|
||||
.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), b"0 w 10 10 m 100 10 l S 2 w 10 20 m 100 20 l S")));
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Ref(cs))).unwrap();
|
||||
let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("lines.pdf", None, bytes.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let id = h.state().views[0].id;
|
||||
assert!(h.state_mut().execute("prepress.hairlines"));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, Some(Dialog::FixHairlines));
|
||||
h.get_by_label("Type 3 font glyphs");
|
||||
h.get_by_label("Fix").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().toast.clone().unwrap().0, "Fixed 1 hairline on 1 page");
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Fix hairlines"));
|
||||
// Nothing left: told, and no new undo step.
|
||||
assert!(h.state_mut().execute("prepress.hairlines"));
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fix").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().toast.clone().unwrap().0, "No hairlines found on those pages");
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Fix hairlines"));
|
||||
}
|
||||
|
||||
/// Several strokes on one page: the notice says "hairlines" and "1 page".
|
||||
#[test]
|
||||
fn fix_hairlines_notice_uses_singular_and_plural() {
|
||||
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap();
|
||||
for (n, page) in pdfcraft_model::pages(&doc).into_iter().enumerate() {
|
||||
let lines = if n == 0 { "0 w 10 10 m 100 10 l S 0 w 10 20 m 100 20 l S" } else { "2 w 10 10 m 100 10 l S" };
|
||||
let cs = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), lines.as_bytes())));
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Ref(cs))).unwrap();
|
||||
}
|
||||
let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("lines.pdf", None, bytes.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
// The dialog's default range is every page; only the first has hairlines.
|
||||
assert!(h.state_mut().execute("prepress.hairlines"));
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fix").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().toast.clone().unwrap().0, "Fixed 2 hairlines on 1 page");
|
||||
}
|
||||
|
||||
/// The `dialog` option goes through the command: with no document it does not open the dialog.
|
||||
#[test]
|
||||
fn fix_hairlines_option_needs_a_document() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("dialog", "fix-hairlines").unwrap();
|
||||
assert_eq!(app.dialog, None);
|
||||
}
|
||||
|
||||
/// The Read mode command (not the mode bar) applies a paragraph still open in the editor, as
|
||||
/// putting the Edit text tool down does; left open, it would stay drawn in Read mode with no tool.
|
||||
#[test]
|
||||
|
||||
+3
-3
@@ -363,9 +363,9 @@ update the numbers.
|
||||
### 18. Print production tools absent
|
||||
|
||||
- **Missing:** Output Preview, separations, total area coverage, overprint preview, ink manager,
|
||||
colour conversion, printer marks, fix hairlines, flattener preview, colour management settings;
|
||||
print as image; PostScript output.
|
||||
- **Evidence:** 18 print-production features planned (L area).
|
||||
colour conversion, printer marks, flattener preview, colour management settings;
|
||||
print as image; PostScript output. Fix hairlines is in (Print production ▸ Fix hairlines…).
|
||||
- **Evidence:** 17 print-production features planned (L area).
|
||||
- **Estimate:** 25–45 h (part of the L area's 30–55). **Doc:** [target-app-parity.md](target-app-parity.md).
|
||||
|
||||
### 19. Preferences mostly missing
|
||||
|
||||
@@ -8558,11 +8558,11 @@ title = "Fix hairlines"
|
||||
area = "L"
|
||||
tier = "P1"
|
||||
milestone = "M11"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["prepress.hairlines"]
|
||||
tools = ["doc_fix_hairlines"]
|
||||
evidence = ["crates/edit/src/tests.rs::hairlines_are_widened_in_page_content_and_forms", "crates/ui-egui/tests/it/editing.rs::fix_hairlines_from_the_dialog", "crates/automation/tests/it/automation.rs::fix_hairlines_through_tools"]
|
||||
notes = "Print production ▸ Fix hairlines…: strokes narrower than a threshold (default 0.003 in; zero-width lines always) get a width (default 0.01 in), in page content and form XObjects, measured after every transformation; Type 3 glyphs and tiling patterns only when asked; all, current or a range of pages; the dialog says how many strokes changed and tells when there is nothing to fix (no edit, no undo step). Tool doc_fix_hairlines. Still missing against Acrobat: nothing of the dialog; hairlines inside annotation appearances are left alone. Type 3 glyphs and tiling patterns change in place, so a page outside the range using one changes too: the tool's elsewhere counts those, the dialog does not say so yet."
|
||||
|
||||
[[feature]]
|
||||
id = "print.flattener-preview"
|
||||
|
||||
Reference in new issue
Block a user