mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
Text: tracked glyphs retain their word boundaries (#3477)
* Text: tracked glyphs retain their word boundaries * M7: cap inferred tracking at half an em so one-glyph words and cells keep their spaces Adds the regression test from the #3477 review (fails without the cap). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
1bb3e916fe
commit
32abf71049
@@ -191,6 +191,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
|
||||
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
|
||||
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
|
||||
- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
|
||||
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
|
||||
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged.
|
||||
|
||||
@@ -904,6 +904,92 @@ fn text_page(content: &str) -> Document {
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_text_keeps_words_in_paragraphs_and_objects() {
|
||||
let expected = "TRACKED SAMPLE TEXT";
|
||||
for spacing in [1.5, 2.0001, 3.0] {
|
||||
for scale in [50.0, 100.0, 150.0] {
|
||||
for positioned in [false, true] {
|
||||
let mut show = String::new();
|
||||
if !positioned {
|
||||
show.push('[');
|
||||
}
|
||||
for (i, c) in expected.chars().enumerate() {
|
||||
if positioned {
|
||||
show.push_str(&format!("({c}) Tj "));
|
||||
if i + 1 < expected.len() {
|
||||
show.push_str(&format!("[{}] TJ ", -spacing * 100.0));
|
||||
}
|
||||
} else {
|
||||
show.push_str(&format!("({c}) "));
|
||||
if i + 1 < expected.len() {
|
||||
show.push_str(&format!("{} ", -spacing * 100.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !positioned {
|
||||
show.push_str("] TJ");
|
||||
}
|
||||
let doc = text_page(&format!("BT /F1 10 Tf {scale} Tz 72 700 Td {show} ET"));
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, expected, "{spacing}, {scale}, {positioned}");
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
|
||||
assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected));
|
||||
}
|
||||
}
|
||||
let doc = text_page(&format!("BT /F1 10 Tf {spacing} Tc 72 700 Td ({expected}) Tj ET"));
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_glyph_words_and_cells_are_not_tracking() {
|
||||
for (content, expected) in [
|
||||
("BT /F1 10 Tf 72 700 Td [(a) -1000 (b) -1000 (c) -1000 (d)] TJ ET", "a b c d"),
|
||||
("BT /F1 10 Tf 72 700 Td [(1) -2500 (2) -2500 (3) -2500 (4) -2500 (5)] TJ ET", "1 2 3 4 5"),
|
||||
("BT /F1 10 Tf 72 700 Td [(A) -300 (B) -1200 (C) -1200 (D) -1200 (E) -300 (F) -1200 (G)] TJ ET", "A B C D E F G"),
|
||||
] {
|
||||
assert_eq!(text::text_blocks(&text_page(content), 0).unwrap()[0].text, expected, "{content}");
|
||||
}
|
||||
let mut s = String::from("BT /F1 10 Tf ");
|
||||
for (i, c) in "12345".chars().enumerate() {
|
||||
s.push_str(&format!("1 0 0 1 {} 700 Tm ({c}) Tj ", 72 + i * 12));
|
||||
}
|
||||
s.push_str("ET");
|
||||
assert_eq!(text::text_blocks(&text_page(&s), 0).unwrap()[0].text, "1 2 3 4 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_text_preserves_larger_word_gaps() {
|
||||
for show in [
|
||||
"[(T) -300 (E) -300 (S) -300 (T) -600 (W) -300 (O) -300 (R) -300 (D)] TJ",
|
||||
"(T) Tj [-300] TJ (E) Tj [-300] TJ (S) Tj [-300] TJ (T) Tj [-600] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj",
|
||||
"[(TEST) -300 (WORD)] TJ",
|
||||
"(TEST) Tj [-300] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj",
|
||||
] {
|
||||
let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET"));
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, "TEST WORD", "{show}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_text_positions_do_not_count_character_spacing_twice() {
|
||||
let expected = "TRACKED SAMPLE TEXT";
|
||||
for char_spacing in [0.0, 2.0] {
|
||||
for transform in ["1 0 0 1 0 0", "0 1 -1 0 800 0"] {
|
||||
let mut content = format!("q {transform} cm BT /F1 10 Tf {char_spacing} Tc ");
|
||||
let mut x = 72.0;
|
||||
for c in expected.chars() {
|
||||
content.push_str(&format!("1 0 0 1 {x:.4} 700 Tm ({c}) Tj "));
|
||||
x += pdfcraft_fonts::helvetica_width(&c.to_string(), 10.0) + 2.0001;
|
||||
}
|
||||
content.push_str("ET Q");
|
||||
let doc = text_page(&content);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
|
||||
assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor_text_page() -> Document {
|
||||
let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET";
|
||||
let objs: Vec<String> = vec![
|
||||
|
||||
+82
-6
@@ -635,6 +635,7 @@ struct Shown {
|
||||
glyph_count: usize,
|
||||
/// Advance in text space, including spacing and TJ adjustments.
|
||||
advance: f64,
|
||||
tracking: f64,
|
||||
/// The text matrix where it starts (text space), and the state there and at its `BT`.
|
||||
tm: Matrix,
|
||||
tlm: Matrix,
|
||||
@@ -702,6 +703,77 @@ impl Pen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the lower third of letter gaps, not word gaps. A pair of isolated labels is not
|
||||
/// evidence of tracking; unadjusted neighbours inside a string contribute zero too.
|
||||
/// The tracking a run of glyphs was set with: the lower third of its gaps, when that is at
|
||||
/// most half an em (`limit`). A wider "tracking" is word or cell spacing between one-glyph
|
||||
/// words (`a b c`, a column of digits or Y/N ticks), which must keep its spaces.
|
||||
fn typical_tracking(gaps: &mut [f64], limit: f64) -> f64 {
|
||||
if gaps.len() < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
let at = gaps.len() / 3;
|
||||
gaps.select_nth_unstable_by(at, f64::total_cmp);
|
||||
gaps.get(at).copied().filter(|n| n.is_finite() && *n <= limit).unwrap_or(0.0).max(0.0)
|
||||
}
|
||||
|
||||
fn tj_tracking(pieces: &[Object], metrics: &Metrics, size: f64, scale: f64) -> f64 {
|
||||
let mut gaps = Vec::new();
|
||||
let mut previous = false;
|
||||
let mut gap = 0.0;
|
||||
for piece in pieces {
|
||||
if let Object::String(s) = piece {
|
||||
for (code, len) in metrics.codes(&s.bytes) {
|
||||
let blank = metrics.is_space(code, len) || metrics.text_of(code).is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
|
||||
if previous && !blank {
|
||||
gaps.push(gap);
|
||||
}
|
||||
previous = !blank;
|
||||
gap = 0.0;
|
||||
}
|
||||
} else if let Some(n) = piece.as_f64() {
|
||||
gap += -n / 1000.0 * size * scale;
|
||||
}
|
||||
}
|
||||
typical_tracking(&mut gaps, size * scale.abs() * 0.5)
|
||||
}
|
||||
|
||||
fn positioned_tracking(shown: &mut [Shown]) {
|
||||
let mut start = 0;
|
||||
while start < shown.len() {
|
||||
let mut end = start.saturating_add(1);
|
||||
while let (Some(prev), Some(next)) = (shown.get(end.saturating_sub(1)), shown.get(end)) {
|
||||
if prev.glyph_count != 1
|
||||
|| next.glyph_count != 1
|
||||
|| prev.font != next.font
|
||||
|| prev.scope != next.scope
|
||||
|| (prev.size - next.size).abs() > 0.01
|
||||
|| prev.state.char_spacing != next.state.char_spacing
|
||||
|| prev.state.scale != next.state.scale
|
||||
|| !Pen::after(prev).continues(next, 1.5).0
|
||||
{
|
||||
break;
|
||||
}
|
||||
end = end.saturating_add(1);
|
||||
}
|
||||
if let Some(run) = shown.get_mut(start..end) {
|
||||
let mut gaps: Vec<f64> = run
|
||||
.windows(2)
|
||||
.filter_map(|pair| {
|
||||
let [prev, next] = pair else { return None };
|
||||
(!prev.text.trim().is_empty() && !next.text.trim().is_empty()).then(|| Pen::after(prev).continues(next, 0.0).1)
|
||||
})
|
||||
.collect();
|
||||
let limit = run.first().map_or(0.0, |s| s.size * s.state.scale.abs() * 0.5);
|
||||
let tracking = typical_tracking(&mut gaps, limit);
|
||||
for s in run.iter_mut().skip(1) {
|
||||
s.tracking = tracking;
|
||||
}
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphics state carried from one of a page's content streams to the next: the streams
|
||||
/// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first
|
||||
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
|
||||
@@ -883,6 +955,8 @@ fn interpret(
|
||||
b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(),
|
||||
_ => op.operands.last().cloned().into_iter().collect(),
|
||||
};
|
||||
// Tc is already included in the pen advance; TJ may encode tracking instead.
|
||||
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
|
||||
let trm0 = tm.then(&ts.ctm);
|
||||
let start = trm0.apply(0.0, ts.rise);
|
||||
let mut text = String::new();
|
||||
@@ -924,12 +998,12 @@ fn interpret(
|
||||
if let Some(n) = other.as_f64() {
|
||||
let dx = -n / 1000.0 * ts.size * ts.scale;
|
||||
// A large gap inside TJ reads as a space.
|
||||
if dx > ts.size * 0.2 && !text.ends_with(' ') {
|
||||
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
// Kerning keeps a word together; a jump either way of more
|
||||
// than 0.2 em ends it (whatever the sign of the size or `Tz`).
|
||||
if dx.abs() > (ts.size * ts.scale).abs() * 0.2 {
|
||||
// Tracking and kerning keep a word together; a larger jump
|
||||
// ends it (whatever the sign of the size or `Tz`).
|
||||
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
|
||||
in_word = false;
|
||||
}
|
||||
x_text += dx;
|
||||
@@ -976,6 +1050,7 @@ fn interpret(
|
||||
extra_ops: Vec::new(),
|
||||
glyph_count,
|
||||
advance: x_text,
|
||||
tracking: 0.0,
|
||||
tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]),
|
||||
state: ts.clone(),
|
||||
tlm,
|
||||
@@ -1009,6 +1084,7 @@ fn interpret(
|
||||
carry.ts = ts;
|
||||
carry.stack = stack;
|
||||
carry.marked = marked;
|
||||
positioned_tracking(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1057,7 +1133,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
});
|
||||
if joins && let Some(l) = lines.last_mut() {
|
||||
let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1);
|
||||
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
l.text.push(' ');
|
||||
}
|
||||
l.text.push_str(&s.text);
|
||||
@@ -1147,7 +1223,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
});
|
||||
if joins && let Some(l) = lines.last_mut() {
|
||||
let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1);
|
||||
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
l.text.push(' ');
|
||||
}
|
||||
l.text.push_str(&s.text);
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ total matches the parity estimate.
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
|
||||
Reference in New Issue
Block a user