From 7a7103c4142a9bdecb4a81a6f2de024e874cf711 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Thu, 5 Nov 2026 06:37:33 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/src/pages.rs | 13 +- crates/automation/src/tests.rs | 11 ++ crates/automation/tests/it/automation.rs | 51 +++--- crates/engine/src/rich_copy.rs | 153 ++++++++++++++++-- crates/export/src/clip.rs | 190 ++++++++++++++++------- crates/ui-egui/src/app/clipboard.rs | 2 + crates/ui-egui/src/canvas/selection.rs | 5 +- crates/ui-egui/tests/it/column_select.rs | 22 +++ 8 files changed, 349 insertions(+), 98 deletions(-) diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index b3439719a2..8b076b088c 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -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 = 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) } diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index ed1a0ecb81..ad0aa039dc 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -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); } diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 3041853cec..ceef8a7709 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -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 { 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"), "{html}"); + assert!(html.contains("Slanted"), "{html}"); + assert!(html.contains("color:#0000FF\">Blue"), "{html}"); } #[test] diff --git a/crates/engine/src/rich_copy.rs b/crates/engine/src/rich_copy.rs index c1d6f8e9b6..f29ced8142 100644 --- a/crates/engine/src/rich_copy.rs +++ b/crates/engine/src/rich_copy.rs @@ -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 { 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, bool)> = None; + // The previous glyph's line, block and box, and whether it was a hyphen. + let mut prev: Option<(usize, Option, [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| 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, 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], 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, steps: &mut usize) -> Option> { 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, 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 Vec { 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 = (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| p.iter().map(|s| s.text.as_str()).collect::(); + let paragraphs: Vec = 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 = (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(); diff --git a/crates/export/src/clip.rs b/crates/export/src/clip.rs index a782f27a18..70a29c0026 100644 --- a/crates/export/src/clip.rs +++ b/crates/export/src/clip.rs @@ -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, + /// Family names to try in order ([`font_family`]); empty keeps the target's own. + pub families: Vec, /// 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 { +/// 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 { 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 { 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 = 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 { @@ -155,47 +181,57 @@ fn safe_family(family: &str) -> Option { (!kept.is_empty()).then(|| kept.to_owned()) } -/// The copied text as an HTML fragment: one `

` per paragraph, a `` per run with its -/// family, size and colour inline, bold as `` and italic as `` (so a target that -/// drops style attributes still keeps them). Text is escaped; a line break is `
` 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 `

` +/// per paragraph, a `` per run with its families, size and colour inline, bold as +/// `` and italic as `` (so a target that drops style attributes still keeps them). +/// Text is escaped; a line break is `
`, and a space HTML would collapse (after another +/// space, at a paragraph's start or after a break, across runs too) is ` `. 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 = "

"; const CLOSE: &str = "

\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 = 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!("{body}"); } @@ -205,23 +241,23 @@ fn span_html(span: &ClipSpan) -> String { if css.is_empty() { body } else { format!("{body}", esc(&css.join(";"))) } } -/// Escape for HTML text in one pass; `\n` becomes `
` and a run of spaces keeps its width -/// with ` `. Dropped: what [`esc`] drops (C0 controls), C1 controls, a carriage return +/// Escape for HTML text in one pass; `\n` becomes `
`, and a space after a space or a break +/// (`space_before` carries that in and out) is ` `, 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("
"), - ' ' if prev_space => out.push_str(" "), + ' ' if *space_before => out.push_str(" "), '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), 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, "

Title

\n\ @@ -272,10 +309,10 @@ mod tests { #[test] fn special_characters_are_escaped_and_cannot_leave_the_style() { - let st = ClipStyle { family: Some("Evil';}