M3: copy with formatting: review fixes (spaces, family names, caps, wraps)

- Spaces HTML would collapse keep their width across runs, at a
  paragraph's start and after a break.
- font_family returns the families to try: spaced, then as spelt
  ('Sim Sun','SimSun'); a hyphen splits only before a style word, so
  MS-Gothic stays whole.
- clip_html reports its cut; empty paragraphs count against the cap;
  the span cap is checked before a paragraph opens; ClipBuilder's
  Default starts a paragraph; a negative size clamps to 1pt.
- RichCopy::truncated: a capped copy is marked, the clipboard then puts
  plain text only, and text_extract sets html_truncated. No HTML for any
  page after the shared budget runs out.
- A style change at a line end is a wrap, not a paragraph; the block
  hint is skipped when a smaller block overlaps it; no editor reports
  styles_incomplete.
- A column selection copies plain text only.

Each new test fails with its fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76andClaude Opus 5.5 committed 2026-11-05 06:37:33 -05:00
1 parent ecb5cae460
commit 7a7103c414
8 files changed
+349 -98

No files matched your search

+8 -5
View File
@@ -230,14 +230,16 @@ impl Automation {
};
let doc = self.doc(a)?;
let mut out = Vec::with_capacity(pages.len());
let (mut html_left, mut html_truncated) = (html_budget, false);
// `spent`: the budget ran out, so later pages get no HTML. `cut`: some page's HTML is
// missing part of its text, by the budget or by the copy's own caps.
let (mut html_left, mut spent, mut cut) = (html_budget, false, false);
for (&p, t) in pages.iter().zip(texts) {
let mut page = json!({ "page": p + 1, "text": text(&t) });
if html {
// Copy with formatting: the same glyphs as an HTML fragment, within one budget for
// the whole call; a page past it gets none and the call says so.
let mut copy = pdfcraft_engine::RichCopy::default();
if !html_truncated {
if !spent {
let glyphs: Vec<usize> = match rect {
Some(r) => t.glyphs_in(r),
None => (0..t.glyphs.len()).collect(),
@@ -246,19 +248,20 @@ impl Automation {
match html_left.checked_sub(copy.html.len()) {
Some(left) => html_left = left,
None => {
html_truncated = true;
spent = true;
copy = pdfcraft_engine::RichCopy::default();
}
}
}
cut |= spent || copy.truncated;
page["html"] = json!(copy.html);
page["styles_incomplete"] = json!(copy.styles_incomplete || html_truncated);
page["styles_incomplete"] = json!(copy.styles_incomplete || spent);
}
out.push(page);
}
let mut result = json!({ "pages": out });
if html {
result["html_truncated"] = json!(html_truncated);
result["html_truncated"] = json!(cut);
}
Ok(result)
}
+11
View File
@@ -570,4 +570,15 @@ fn text_extract_html_has_one_budget_across_pages() {
assert_eq!(pages[2]["text"], "Page 3", "the plain text is not capped by the HTML budget");
// Plain text alone says nothing about HTML.
assert!(a.text_extract(&Args(&json!({ "doc": id.0 }))).unwrap().get("html_truncated").is_none());
// A smaller page after the one that overflowed still gets none: "Page 1" fits what "Page 10"
// did not.
let id = search_document(&mut a, 10);
let args = json!({ "doc": id.0, "pages": [10, 1], "format": "html" });
let full = a.text_extract(&Args(&args)).unwrap();
let (ten, one) = (full["pages"][0]["html"].as_str().unwrap().len(), full["pages"][1]["html"].as_str().unwrap().len());
assert!(one < ten, "{full}");
let capped = a.text_extract_within(&Args(&args), ten - 1).unwrap();
assert_eq!(capped["pages"][0]["html"], "");
assert_eq!(capped["pages"][1]["html"], "", "no HTML after the budget ran out: {capped}");
assert_eq!(capped["pages"][1]["styles_incomplete"], true);
}
+30 -21
View File
@@ -8918,27 +8918,15 @@ fn a_deleted_image_is_not_kept_by_reduce_or_optimize() {
/// page is turned with /Rotate 90.
fn table_pdf() -> Vec<u8> {
let body = "BT /F1 12 Tf 20 250 Td (Name) Tj 130 0 Td (Qty) Tj -130 -20 Td (Apple) Tj 130 0 Td (12) Tj -130 -20 Td (Pear) Tj 130 0 Td (7) Tj ET";
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 300] >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
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{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
let stream = format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len());
pdf_of(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 300] >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
&stream,
])
}
/// Issue #740: `text_extract` with `rect` takes what Column select takes, in the displayed-page
@@ -9006,6 +8994,27 @@ fn text_extract_as_html_keeps_the_formatting() {
let e = a.call("text_extract", &json!({ "doc": doc, "format": bad })).unwrap_err();
assert!(matches!(e, ToolError::InvalidArgs(_)), "{bad}: {e}");
}
// Bold, italic, colour and size come through end to end.
let body = "BT /F1 18 Tf 20 250 Td (Big) Tj ET BT /F2 12 Tf 20 200 Td (Strong) Tj ET \
BT /F3 12 Tf 20 150 Td (Slanted) Tj ET BT 0 0 1 rg /F1 12 Tf 20 100 Td (Blue) Tj ET";
let stream = format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len());
let styled = pdf_of(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>",
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R /F3 7 0 R >> >> >>",
&stream,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Oblique >>",
]);
std::fs::write(dir.join("styled.pdf"), styled).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap();
let page = &ok(&mut a, "text_extract", json!({ "doc": doc, "format": "html" }))["pages"][0];
let html = page["html"].as_str().unwrap();
assert!(html.contains("font-size:18pt\">Big"), "{html}");
assert!(html.contains("<strong>Strong</strong>"), "{html}");
assert!(html.contains("<em>Slanted</em>"), "{html}");
assert!(html.contains("color:#0000FF\">Blue"), "{html}");
}
#[test]
+137 -16
View File
@@ -23,9 +23,11 @@ pub struct RichCopy {
pub text: ClipText,
/// The HTML fragment for the clipboard (empty when nothing was selected).
pub html: String,
/// Styles were left out: reading stopped at its limits on this page, or matching glyphs to
/// blocks ran out of steps.
/// Styles were left out: the document has no editor to read them, reading stopped at its
/// limits on this page, or matching glyphs to blocks ran out of steps.
pub styles_incomplete: bool,
/// A cap left part of the selection out of `text` and `html`; the plain text has all of it.
pub truncated: bool,
}
struct StyledBlock {
@@ -44,25 +46,28 @@ impl crate::Document {
fn rich_copy_within(&self, page: usize, text: &PageText, glyphs: &[usize], mut steps: usize) -> Result<RichCopy, EditError> {
self.check_copying()?;
let (blocks, mut incomplete) = self.styled_blocks(page);
let mut nested = vec![None; blocks.len()];
let mut b = ClipBuilder::new();
// The previous glyph's line and block, and whether it was a hyphen.
let mut prev: Option<(usize, Option<usize>, bool)> = None;
// The previous glyph's line, block and box, and whether it was a hyphen.
let mut prev: Option<(usize, Option<usize>, [f32; 4], bool)> = None;
for &i in glyphs {
let (Some(g), Some(&line)) = (text.glyphs.get(i), text.line_of.get(i)) else { continue };
let found = block_at(&blocks, g.rect, prev.and_then(|(_, b, _)| b), &mut steps).unwrap_or_else(|| {
let hint = prev.and_then(|(_, b, _, _)| b).filter(|&k| !holds_smaller(&blocks, &mut nested, k, &mut steps));
let found = block_at(&blocks, g.rect, hint, &mut steps).unwrap_or_else(|| {
incomplete = true;
None
});
let line = usize::try_from(line).unwrap_or(usize::MAX);
if let Some((prev_line, prev_block, hyphen)) = prev {
if let Some((prev_line, prev_block, prev_rect, hyphen)) = prev {
if line != prev_line {
if found == prev_block && found.is_some() {
let style = |k: Option<usize>| k.and_then(|k| blocks.get(k)).map(|s| &s.style);
if found.is_none() && prev_block.is_none() {
b.line_break();
} else if found == prev_block || wraps(style(prev_block), style(found), prev_rect, g.rect) {
// A paragraph wrapping: one space, none after a hyphen.
if !hyphen {
b.space();
}
} else if found.is_none() && prev_block.is_none() {
b.line_break();
} else {
b.paragraph();
}
@@ -74,16 +79,16 @@ impl crate::Document {
if !b.push(&g.text, style.unwrap_or(&ClipStyle::default())) {
break;
}
prev = Some((line, found, g.text.ends_with('-')));
prev = Some((line, found, g.rect, g.text.ends_with('-')));
}
let text = b.finish();
Ok(RichCopy { html: clip_html(&text), text, styles_incomplete: incomplete })
Ok(finish(b, incomplete))
}
/// `page`'s reading blocks in view space with their style, and whether reading stopped at
/// its limits.
fn styled_blocks(&self, page: usize) -> (Vec<StyledBlock>, bool) {
let (Some(editor), Some(info)) = (self.editor.as_ref(), self.info.pages.get(page)) else { return (Vec::new(), false) };
let Some(info) = self.info.pages.get(page) else { return (Vec::new(), false) };
let Some(editor) = self.editor.as_ref() else { return (Vec::new(), true) };
let Ok(reading) = pdfcraft_edit::reading_blocks_checked(&editor.cos, page) else { return (Vec::new(), true) };
let unit = display_unit(info);
let blocks = reading
@@ -93,7 +98,7 @@ impl crate::Document {
.map(|b| {
let (bold, italic) = weight_and_slant(&b.base_font);
let style = ClipStyle {
family: pdfcraft_export::font_family(&b.base_font),
families: pdfcraft_export::font_family(&b.base_font),
size: b.size * unit,
bold: b.bold || bold,
italic: b.italic || italic,
@@ -113,8 +118,56 @@ fn view_rect(info: &PageInfo, r: [f64; 4]) -> [f32; 4] {
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
}
/// The block whose box holds the centre of `glyph` (view space): `hint` first, then the
/// smallest that holds it. Each block tested costs a step; `None` when the steps ran out first.
/// The copy `b` collected, with its HTML; `truncated` when a cap cut the text or the HTML.
fn finish(b: ClipBuilder, styles_incomplete: bool) -> RichCopy {
let text = b.finish();
let (html, cut) = clip_html(&text);
RichCopy { html, truncated: text.truncated || cut, text, styles_incomplete }
}
/// Whether the next line starts inside the same paragraph although its first glyph is in another
/// block: blocks split where the style changes, so a style change at a line's end is still a
/// wrap when the size stays and the lines sit no further apart than their own height allows.
/// Two blocks of the same style, a change of size, or a gap of over half a line are paragraphs.
fn wraps(prev: Option<&ClipStyle>, next: Option<&ClipStyle>, prev_rect: [f32; 4], rect: [f32; 4]) -> bool {
let (Some(prev), Some(next)) = (prev, next) else { return false };
if prev == next || (prev.size - next.size).abs() > 0.01 {
return false;
}
let height = (prev_rect[3] - prev_rect[1]).max(rect[3] - rect[1]);
let gap = rect[1] - prev_rect[3];
height > 0.0 && gap.is_finite() && gap <= height / 2.0
}
/// Whether a block smaller than block `k` overlaps it, so a glyph inside `k` may belong to that
/// one instead. Worked out once per block (`nested` keeps it), at a step per block tested; when
/// the steps run out the answer is yes, so the full search decides.
fn holds_smaller(blocks: &[StyledBlock], nested: &mut [Option<bool>], k: usize, steps: &mut usize) -> bool {
if let Some(Some(known)) = nested.get(k) {
return *known;
}
let Some(outer) = blocks.get(k) else { return true };
let area = |r: &[f32; 4]| (r[2] - r[0]) * (r[3] - r[1]);
let overlaps = |r: &[f32; 4]| r[0] <= outer.rect[2] && r[2] >= outer.rect[0] && r[1] <= outer.rect[3] && r[3] >= outer.rect[1];
let mut found = false;
for (j, b) in blocks.iter().enumerate() {
if *steps == 0 {
return true;
}
*steps -= 1;
if j != k && overlaps(&b.rect) && area(&b.rect) < area(&outer.rect) {
found = true;
break;
}
}
if let Some(slot) = nested.get_mut(k) {
*slot = Some(found);
}
found
}
/// The block whose box holds the centre of `glyph` (view space): `hint` first (a block no
/// smaller one overlaps), then the smallest that holds it. Each block tested costs a step; `None` when the steps ran out first.
fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option<usize>, steps: &mut usize) -> Option<Option<usize>> {
let (x, y) = ((glyph[0] + glyph[2]) / 2.0, (glyph[1] + glyph[3]) / 2.0);
let holds = |r: &[f32; 4]| x >= r[0] - SLACK && x <= r[2] + SLACK && y >= r[1] - SLACK && y <= r[3] + SLACK;
@@ -144,6 +197,8 @@ fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option<usize>, steps:
mod tests {
use std::sync::Arc;
use pdfcraft_export::{ClipBuilder, ClipStyle};
use crate::Session;
/// A heading in bold, a body paragraph of two lines with HTML-special characters, and a red
@@ -153,6 +208,11 @@ mod tests {
BT /F2 12 Tf 20 200 Td (Body text) Tj ET \
BT 1 0 0 rg /F2 12 Tf 90 200 Td (red) Tj ET \
BT /F2 12 Tf 20 186 Td (a<b & c) Tj ET";
page_with(content)
}
/// A one-page PDF drawing `content` with `/F1` Helvetica-Bold and `/F2` Helvetica.
fn page_with(content: &[u8]) -> Vec<u8> {
pdfcraft_testkit::Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>")
@@ -228,6 +288,67 @@ mod tests {
assert!(!page_less.styles_incomplete);
}
fn copy_all(s: &Session, id: crate::DocId) -> super::RichCopy {
let text = page_text(s, id);
let all: Vec<usize> = (0..text.glyphs.len()).collect();
s.get(id).unwrap().rich_copy(0, &text, &all).unwrap()
}
#[test]
fn a_style_change_at_a_line_end_stays_in_its_paragraph() {
let content = b"BT /F2 12 Tf 20 200 Td (plain then) Tj ET \
BT /F1 12 Tf 85 200 Td (bold) Tj ET \
BT /F2 12 Tf 20 186 Td (continues here) Tj ET \
BT /F2 12 Tf 20 120 Td (Another paragraph) Tj ET";
let mut s = Session::new();
let id = s.open("wrap.pdf", None, Arc::new(page_with(content)), None).unwrap();
let copy = copy_all(&s, id);
let text = |p: &Vec<pdfcraft_export::ClipSpan>| p.iter().map(|s| s.text.as_str()).collect::<String>();
let paragraphs: Vec<String> = copy.text.paragraphs.iter().map(text).collect();
assert!(paragraphs.iter().any(|p| p.contains("bold") && p.contains("continues")), "{paragraphs:?}");
assert!(paragraphs.iter().any(|p| p.contains("Another") && !p.contains("continues")), "a wide gap is still a paragraph: {paragraphs:?}");
}
#[test]
fn a_styled_word_inside_a_paragraph_keeps_its_style() {
let content = b"BT /F2 12 Tf 20 200 Td (first row of the text) Tj ET \
BT /F2 12 Tf 20 186 Td (second) Tj ET \
BT /F1 12 Tf 62 186 Td (bold) Tj ET \
BT /F2 12 Tf 92 186 Td (row) Tj ET";
let mut s = Session::new();
let id = s.open("nested.pdf", None, Arc::new(page_with(content)), None).unwrap();
let copy = copy_all(&s, id);
let spans: Vec<_> = copy.text.paragraphs.iter().flatten().collect();
assert!(spans.iter().any(|s| s.text.contains("bold") && s.style.bold), "{spans:?}");
assert!(spans.iter().any(|s| s.text.contains("second") && !s.style.bold), "{spans:?}");
}
#[test]
fn a_capped_copy_is_marked_truncated() {
let (plain, bold) = (ClipStyle::default(), ClipStyle { bold: true, ..ClipStyle::default() });
let mut b = ClipBuilder::new();
for k in 0..=pdfcraft_export::MAX_CLIP_SPANS {
b.push("x", if k % 2 == 0 { &plain } else { &bold });
}
let copy = super::finish(b, false);
assert!(copy.truncated && copy.text.truncated);
let mut b = ClipBuilder::new();
b.push("x", &plain);
assert!(!super::finish(b, false).truncated);
}
#[test]
fn without_an_editor_the_styles_are_incomplete() {
let mut s = Session::new();
let id = s.open("styled.pdf", None, Arc::new(styled_page()), None).unwrap();
let text = page_text(&s, id);
let all: Vec<usize> = (0..text.glyphs.len()).collect();
s.doc_mut(id).unwrap().editor = None;
let copy = s.get(id).unwrap().rich_copy(0, &text, &all).unwrap();
assert!(copy.styles_incomplete && !copy.html.contains("font-size"), "{}", copy.html);
assert!(!copy.truncated);
}
#[test]
fn copy_with_formatting_is_refused_when_copying_is_withheld() {
let mut cos = pdfcraft_cos::Document::open(Arc::new(styled_page())).unwrap();
+136 -54
View File
@@ -19,8 +19,8 @@ const MAX_FAMILY_CHARS: usize = 64;
/// How a span looks.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ClipStyle {
/// The family a word processor knows ([`font_family`]); `None` keeps the target's own.
pub family: Option<String>,
/// Family names to try in order ([`font_family`]); empty keeps the target's own.
pub families: Vec<String>,
/// Size in points; zero or not finite leaves the target's size, and any other is written
/// within 1 to 1000.
pub size: f64,
@@ -46,7 +46,7 @@ pub struct ClipText {
/// Builds a [`ClipText`] piece by piece, merging pieces of equal style into one span and
/// stopping at the caps.
#[derive(Debug, Default)]
#[derive(Debug)]
pub struct ClipBuilder {
out: ClipText,
/// The separator owed before the next piece in this paragraph (a space or a line break).
@@ -56,9 +56,15 @@ pub struct ClipBuilder {
bytes: usize,
}
impl Default for ClipBuilder {
fn default() -> Self {
Self { out: ClipText::default(), pending: None, new_paragraph: true, spans: 0, bytes: 0 }
}
}
impl ClipBuilder {
pub fn new() -> Self {
Self { new_paragraph: true, ..Self::default() }
Self::default()
}
/// A word space before the next piece (dropped at the start of a paragraph).
@@ -91,6 +97,11 @@ impl ClipBuilder {
self.out.truncated = true;
return false;
};
let new_span = self.new_paragraph || self.out.paragraphs.last().and_then(|p| p.last()).is_none_or(|last| last.style != *style);
if new_span && self.spans >= MAX_CLIP_SPANS {
self.out.truncated = true;
return false;
}
if self.new_paragraph {
self.out.paragraphs.push(Vec::new());
self.new_paragraph = false;
@@ -103,10 +114,6 @@ impl ClipBuilder {
last.text.push_str(text);
}
last => {
if self.spans >= MAX_CLIP_SPANS {
self.out.truncated = true;
return false;
}
// A separator belongs to the span before it, so a new style starts at its text.
if let (Some(last), Some(sep)) = (last, sep) {
last.text.push(sep);
@@ -124,16 +131,22 @@ impl ClipBuilder {
}
}
/// The family a word processor knows, from a font's `/BaseFont`: subset tag and style suffix
/// removed, `PSMT`/`MT`/`PS` dropped, camel case spaced (`ABCDEF+TimesNewRomanPSMT` →
/// `Times New Roman`). Only letters, digits, spaces, `-`, `_` and `.` are kept, so the name
/// cannot leave its CSS string. `None` when nothing is left.
pub fn font_family(base_font: &str) -> Option<String> {
/// The family names to try for a font's `/BaseFont`, best first: subset tag and style suffix
/// removed, `PSMT`/`MT`/`PS` dropped. The first has camel case and hyphens spaced
/// (`ABCDEF+TimesNewRomanPSMT` → `Times New Roman`, `MS-Gothic` → `MS Gothic`); the second,
/// when it differs, is the name as spelt (`SimSun`). A hyphen ends the family only before a
/// style word (`Arial-BoldMT`). Only letters, digits, spaces, `-`, `_` and `.` are kept, so a
/// name cannot leave its CSS string. Empty when nothing is left.
pub fn font_family(base_font: &str) -> Vec<String> {
let name = match base_font.split_once('+') {
Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest,
_ => base_font,
};
let family = name.split_once([',', '-']).map_or(name, |(family, _)| family);
let name = name.split_once(',').map_or(name, |(family, _)| family);
let family = match name.split_once('-') {
Some((family, style)) if STYLE_WORDS.iter().any(|w| style.starts_with(w)) => family,
_ => name,
};
let family = family.strip_suffix("PSMT").or_else(|| family.strip_suffix("MT")).or_else(|| family.strip_suffix("PS")).unwrap_or(family);
let mut spaced = String::with_capacity(family.len().min(MAX_FAMILY_CHARS * 2));
let mut prev_lower = false;
@@ -142,11 +155,24 @@ pub fn font_family(base_font: &str) -> Option<String> {
spaced.push(' ');
}
prev_lower = c.is_ascii_lowercase();
spaced.push(c);
spaced.push(if c == '-' { ' ' } else { c });
}
safe_family(&spaced)
let spelt: String = family.chars().take(MAX_FAMILY_CHARS).collect();
let mut out: Vec<String> = Vec::with_capacity(2);
for f in [safe_family(&spaced), safe_family(&spelt)].into_iter().flatten() {
if !out.contains(&f) {
out.push(f);
}
}
out
}
/// What may follow a hyphen in a `/BaseFont` when the rest names the style, not the family.
const STYLE_WORDS: &[&str] = &[
"Bold", "Italic", "Oblique", "Regular", "Roman", "Light", "Medium", "Semi", "Demi", "Black", "Heavy", "Thin", "Book", "Cond", "Extra", "Ultra",
"Narrow", "Plain", "Normal", "It", "Bd", "Lt", "Md", "Blk", "MT", "PS",
];
/// `family` with only letters, digits, spaces, `-`, `_` and `.`, so it cannot leave a CSS
/// string; `None` when nothing is left.
fn safe_family(family: &str) -> Option<String> {
@@ -155,47 +181,57 @@ fn safe_family(family: &str) -> Option<String> {
(!kept.is_empty()).then(|| kept.to_owned())
}
/// The copied text as an HTML fragment: one `<p>` per paragraph, a `<span>` per run with its
/// family, size and colour inline, bold as `<strong>` and italic as `<em>` (so a target that
/// drops style attributes still keeps them). Text is escaped; a line break is `<br>` and a run
/// of spaces keeps its width. Never longer than [`MAX_CLIP_HTML_BYTES`]: a span that would pass
/// it ends the fragment.
pub fn clip_html(text: &ClipText) -> String {
/// The copied text as an HTML fragment, and whether [`MAX_CLIP_HTML_BYTES`] cut it: one `<p>`
/// per paragraph, a `<span>` per run with its families, size and colour inline, bold as
/// `<strong>` and italic as `<em>` (so a target that drops style attributes still keeps them).
/// Text is escaped; a line break is `<br>`, and a space HTML would collapse (after another
/// space, at a paragraph's start or after a break, across runs too) is `&nbsp;`. Never longer
/// than the cap: a paragraph or run that would pass it ends the fragment.
pub fn clip_html(text: &ClipText) -> (String, bool) {
const OPEN: &str = "<p style=\"margin:0\">";
const CLOSE: &str = "</p>\n";
let fits = |len: usize, more: usize| len.saturating_add(more).saturating_add(CLOSE.len()) <= MAX_CLIP_HTML_BYTES;
let mut out = String::new();
for spans in &text.paragraphs {
if !fits(out.len(), OPEN.len()) {
return (out, true);
}
let mut p = String::from(OPEN);
// A paragraph's first space would collapse.
let mut space_before = true;
for span in spans {
let s = span_html(span);
if out.len().saturating_add(p.len()).saturating_add(s.len()).saturating_add(CLOSE.len()) > MAX_CLIP_HTML_BYTES {
let s = span_html(span, &mut space_before);
if !fits(out.len().saturating_add(p.len()), s.len()) {
if p.len() > OPEN.len() {
out.push_str(&p);
out.push_str(CLOSE);
}
return out;
return (out, true);
}
p.push_str(&s);
}
p.push_str(CLOSE);
out.push_str(&p);
}
out
(out, false)
}
fn span_html(span: &ClipSpan) -> String {
/// One run as HTML. `space_before` says whether the text before it ended in a space or a break,
/// and is left saying so for the next run.
fn span_html(span: &ClipSpan, space_before: &mut bool) -> String {
let st = &span.style;
let mut css = Vec::new();
if let Some(f) = st.family.as_deref().and_then(safe_family) {
css.push(format!("font-family:'{f}'"));
let families: Vec<String> = st.families.iter().filter_map(|f| safe_family(f)).map(|f| format!("'{f}'")).collect();
if !families.is_empty() {
css.push(format!("font-family:{}", families.join(",")));
}
if st.size.is_finite() && st.size > 0.0 {
if st.size.is_finite() && st.size != 0.0 {
css.push(format!("font-size:{}pt", num(st.size.clamp(*CLIP_SIZE_PT.start(), *CLIP_SIZE_PT.end()))));
}
if let Some(h) = hex(st.color) {
css.push(format!("color:#{h}"));
}
let mut body = text_html(&span.text);
let mut body = text_html(&span.text, space_before);
if st.italic {
body = format!("<em>{body}</em>");
}
@@ -205,23 +241,23 @@ fn span_html(span: &ClipSpan) -> String {
if css.is_empty() { body } else { format!("<span style=\"{}\">{body}</span>", esc(&css.join(";"))) }
}
/// Escape for HTML text in one pass; `\n` becomes `<br>` and a run of spaces keeps its width
/// with `&nbsp;`. Dropped: what [`esc`] drops (C0 controls), C1 controls, a carriage return
/// Escape for HTML text in one pass; `\n` becomes `<br>`, and a space after a space or a break
/// (`space_before` carries that in and out) is `&nbsp;`, so a run of spaces keeps its width.
/// Dropped: what [`esc`] drops (C0 controls), C1 controls, a carriage return
/// and the bidirectional overrides and isolates, which would reorder the pasted text.
fn text_html(text: &str) -> String {
fn text_html(text: &str, space_before: &mut bool) -> String {
let mut out = String::with_capacity(text.len());
let mut prev_space = false;
for c in text.chars().filter(|c| clip_char(*c)) {
match c {
'\n' => out.push_str("<br>"),
' ' if prev_space => out.push_str("&nbsp;"),
' ' if *space_before => out.push_str("&nbsp;"),
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
c => out.push(c),
}
prev_space = c == ' ';
*space_before = matches!(c, ' ' | '\n');
}
out
}
@@ -242,7 +278,7 @@ mod tests {
use super::*;
fn style(size: f64, bold: bool, italic: bool, color: [f64; 3]) -> ClipStyle {
ClipStyle { family: font_family("ABCDEF+Helvetica-Bold"), size, bold, italic, color }
ClipStyle { families: font_family("ABCDEF+Helvetica-Bold"), size, bold, italic, color }
}
#[test]
@@ -261,7 +297,8 @@ mod tests {
let clip = b.finish();
assert_eq!(clip.paragraphs.len(), 2);
assert_eq!(clip.paragraphs.get(1).map(Vec::len), Some(2), "{clip:?}");
let html = clip_html(&clip);
let (html, cut) = clip_html(&clip);
assert!(!cut);
assert_eq!(
html,
"<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>Title</strong></span></p>\n\
@@ -272,10 +309,10 @@ mod tests {
#[test]
fn special_characters_are_escaped_and_cannot_leave_the_style() {
let st = ClipStyle { family: Some("Evil';}</style><script>\\\n".into()), size: f64::NAN, ..ClipStyle::default() };
let st = ClipStyle { families: vec!["Evil';}</style><script>\\\n".into()], size: f64::NAN, ..ClipStyle::default() };
let mut b = ClipBuilder::new();
b.push("<a href=\"x\">&\u{0}\u{1b}</a> two\nline", &st);
let html = clip_html(&b.finish());
let (html, _) = clip_html(&b.finish());
assert_eq!(
html,
"<p style=\"margin:0\"><span style=\"font-family:'Evilstylescript'\">&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt; &nbsp;two<br>line</span></p>\n"
@@ -286,35 +323,65 @@ mod tests {
fn invisible_controls_and_bidi_overrides_are_dropped() {
let mut b = ClipBuilder::new();
b.push("a\u{85}b\u{9F}c\rd\u{202E}e\u{2066}f\u{2069}g\u{202A}h\u{2067}i\tj", &ClipStyle::default());
assert_eq!(clip_html(&b.finish()), "<p style=\"margin:0\">abcdefghi\tj</p>\n");
assert_eq!(clip_html(&b.finish()).0, "<p style=\"margin:0\">abcdefghi\tj</p>\n");
}
#[test]
fn font_sizes_stay_within_range() {
for (size, want) in [(1e300, "1000"), (f64::MAX, "1000"), (2e306, "1000"), (0.2, "1"), (9.96, "10")] {
for (size, want) in [(1e300, "1000"), (f64::MAX, "1000"), (2e306, "1000"), (0.2, "1"), (9.96, "10"), (-12.0, "1"), (f64::MIN, "1")] {
let st = ClipStyle { size, ..ClipStyle::default() };
let mut b = ClipBuilder::new();
b.push("x", &st);
let html = clip_html(&b.finish());
let (html, _) = clip_html(&b.finish());
assert_eq!(html, format!("<p style=\"margin:0\"><span style=\"font-size:{want}pt\">x</span></p>\n"), "{size}");
}
}
#[test]
fn families_read_like_a_word_processor_names_them() {
assert_eq!(font_family("ABCDEF+TimesNewRomanPSMT").as_deref(), Some("Times New Roman"));
assert_eq!(font_family("Arial,BoldItalic").as_deref(), Some("Arial"));
assert_eq!(font_family("Arial-BoldMT").as_deref(), Some("Arial"));
assert_eq!(font_family("abcdef+Foo").as_deref(), Some("abcdefFoo"), "a lower-case prefix is not a subset tag");
assert_eq!(font_family(""), None);
assert_eq!(font_family("+'\"\\;"), None);
assert!(font_family(&"Ab".repeat(10_000)).is_some_and(|f| f.chars().count() <= MAX_FAMILY_CHARS * 2));
let f = |b: &str| font_family(b);
assert_eq!(f("ABCDEF+TimesNewRomanPSMT"), ["Times New Roman", "TimesNewRoman"]);
assert_eq!(f("Arial,BoldItalic"), ["Arial"]);
assert_eq!(f("Arial-BoldMT"), ["Arial"]);
assert_eq!(f("Helvetica-Oblique"), ["Helvetica"]);
assert_eq!(f("MS-Gothic"), ["MS Gothic", "MS-Gothic"], "a hyphen before a family word stays in the name");
assert_eq!(f("SimSun"), ["Sim Sun", "SimSun"], "the name as spelt is tried too");
assert_eq!(f("abcdef+Foo"), ["abcdefFoo"], "a lower-case prefix is not a subset tag");
assert!(f("").is_empty());
assert!(f("+'\"\\;").is_empty());
assert!(f(&"Ab".repeat(10_000)).iter().all(|f| f.chars().count() <= MAX_FAMILY_CHARS * 2));
let st = ClipStyle { families: f("SimSun"), ..ClipStyle::default() };
let mut b = ClipBuilder::new();
b.push("x", &st);
assert_eq!(clip_html(&b.finish()).0, "<p style=\"margin:0\"><span style=\"font-family:'Sim Sun','SimSun'\">x</span></p>\n");
}
#[test]
fn spaces_html_would_collapse_keep_their_width() {
let (a, c) = (ClipStyle::default(), ClipStyle { bold: true, ..ClipStyle::default() });
let mut b = ClipBuilder::new();
b.push(" lead a ", &a);
b.push(" b", &c);
b.line_break();
b.push(" c", &a);
assert_eq!(
clip_html(&b.finish()).0,
"<p style=\"margin:0\">&nbsp;lead a <strong>&nbsp;b<br></strong>&nbsp;c</p>\n",
"at a paragraph's start, across runs and after a break"
);
}
#[test]
fn a_default_builder_starts_a_paragraph() {
let mut b = ClipBuilder::default();
assert!(b.push("x", &ClipStyle::default()));
assert_eq!(b.finish().paragraphs.len(), 1);
}
#[test]
fn empty_and_huge_selections_are_bounded() {
let b = ClipBuilder::new();
assert_eq!(clip_html(&b.finish()), "");
assert_eq!(clip_html(&b.finish()), (String::new(), false));
let mut b = ClipBuilder::new();
b.space();
assert!(b.push("", &ClipStyle::default()));
@@ -332,19 +399,34 @@ mod tests {
let clip = b.finish();
assert!(clip.truncated);
assert_eq!(kept, MAX_CLIP_SPANS);
// Exactly at the cap, a new paragraph is refused before it is opened.
let mut b = ClipBuilder::new();
for i in 0..MAX_CLIP_SPANS {
assert!(b.push("x", if i % 2 == 0 { &a } else { &c }));
}
b.paragraph();
assert!(!b.push("x", &a));
let clip = b.finish();
assert!(clip.truncated);
assert_eq!(clip.paragraphs.len(), 1, "no empty paragraph past the cap");
// One style: the text cap stops it.
let mut b = ClipBuilder::new();
let chunk = "y".repeat(1 << 16);
// Bounded, so the test fails rather than runs on when the cap is gone.
let pushes = (0..MAX_CLIP_TEXT_BYTES / chunk.len() + 2).take_while(|_| b.push(&chunk, &a)).count();
assert!(pushes <= MAX_CLIP_TEXT_BYTES / chunk.len(), "the cap stopped it: {pushes}");
assert_eq!(pushes, MAX_CLIP_TEXT_BYTES / chunk.len(), "the cap stopped it");
let clip = b.finish();
assert!(clip.truncated);
assert!(clip.paragraphs.iter().flatten().map(|s| s.text.len()).sum::<usize>() <= MAX_CLIP_TEXT_BYTES);
// HTML: escaping grows text up to sixfold; the writer still stops at its cap.
let big = ClipText { paragraphs: vec![vec![ClipSpan { text: "<".repeat(1 << 20), style: a.clone() }]; 8], truncated: false };
let html = clip_html(&big);
let (html, cut) = clip_html(&big);
assert!(cut, "the cut is reported");
assert!(html.len() <= MAX_CLIP_HTML_BYTES && html.ends_with("</p>\n"), "{}", html.len());
assert!(html.len() > MAX_CLIP_HTML_BYTES / 2);
// Empty paragraphs count against the cap too.
let empty = ClipText { paragraphs: vec![Vec::new(); MAX_CLIP_HTML_BYTES / 20], truncated: false };
let (html, cut) = clip_html(&empty);
assert!(cut && html.len() <= MAX_CLIP_HTML_BYTES, "{}", html.len());
}
}
+2
View File
@@ -133,6 +133,8 @@ impl PdfCraftApp {
.get(i)
.and_then(DocView::selected_glyphs)
.and_then(|(page, layer, glyphs)| self.session.get(id)?.rich_copy(page, &layer, &glyphs).ok())
// A capped copy would paste less than the plain text holds; paste that instead.
.filter(|copy| !copy.truncated)
.map(|copy| copy.html)
.filter(|html| !html.is_empty())
} else {
+3 -2
View File
@@ -94,7 +94,8 @@ impl DocView {
}
/// The selection's page, its text layer and the selected glyphs in copy order, for Copy with
/// formatting. `None` after Select All of the document, which copies plain text.
/// formatting. `None` after Select All of the document and for a column selection, which
/// copy plain text (a column's rows and tab-separated cells are not paragraphs).
pub(crate) fn selected_glyphs(&self) -> Option<(usize, Arc<PageText>, Vec<usize>)> {
if self.all_text {
return None;
@@ -103,7 +104,7 @@ impl DocView {
let t = self.texts.get(&s.page)?;
let glyphs = match s.span {
Span::Run { anchor, head } => Selection::range(anchor, head).take(t.glyphs.len()).collect(),
Span::Column { rect } => t.glyphs_in(rect),
Span::Column { .. } => return None,
};
Some((s.page, t.clone(), glyphs))
}
+22
View File
@@ -146,3 +146,25 @@ fn alt_drag_with_the_highlighter_marks_only_the_column() {
let marks: Vec<(&str, usize)> = doc.info.annotations.iter().map(|a| (a.subtype.as_str(), a.quads.len())).collect();
assert_eq!(marks, [("Highlight", 4)], "Name, Qty, Apple and 12, each its own quad");
}
/// A column's rows and tab-separated cells are not paragraphs, so Copy puts the plain text on
/// the clipboard and no HTML, even where the clipboard would take it.
#[test]
fn a_column_selection_copies_plain_text_only() {
let offered = std::sync::Arc::new(std::sync::Mutex::new(0usize));
let seen = offered.clone();
let mut h = harness(move |app| {
app.system_clipboard = true;
app.html_clipboard_override = Some(Box::new(move |_, _| {
*seen.lock().unwrap() += 1;
true
}));
});
let (from, to) = top_two_rows(&h);
drag(&mut h, from, to, Modifiers::ALT);
assert!(h.state_mut().execute("edit.copy"));
h.step();
assert_eq!(*offered.lock().unwrap(), 0, "no HTML for a column selection");
let copied = h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "Name\tQty\nApple\t12"));
assert!(copied, "the plain text goes out the usual way");
}