mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
- A line change into a block of another style is a wrap only when the next line starts within half a line below the last one; a line far above it (the next column) starts a paragraph. The size compare treats NaN as "not the same size". - A smaller block must overlap a block's interior, not just touch its edge, to stop the block being used as a hint. - font_family drops only the part after the last hyphen, and only when it names a style (Segoe-UI-Bold gives Segoe UI). - text_extract's description and html_truncated doc now cover a page's own copy caps and say that rect HTML is in line order. - Parity: the new tests are evidence; column selection HTML is listed as missing (plain text only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
434 lines
18 KiB
Rust
434 lines
18 KiB
Rust
//! Copy with formatting: selected text as paragraphs of styled spans, written as an HTML
|
|
//! fragment for the clipboard (inline styles, so a word processor keeps font, size, weight,
|
|
//! slant and colour when it pastes).
|
|
|
|
use crate::as_html::allowed;
|
|
use crate::{esc, hex};
|
|
|
|
/// Spans kept for one copy; text past it is left out and [`ClipText::truncated`] is set.
|
|
pub const MAX_CLIP_SPANS: usize = 20_000;
|
|
/// Text bytes kept for one copy (before escaping).
|
|
pub const MAX_CLIP_TEXT_BYTES: usize = 2 << 20;
|
|
/// The HTML written for one copy never passes this many bytes.
|
|
pub const MAX_CLIP_HTML_BYTES: usize = 16 << 20;
|
|
/// Font sizes are written within this range (points), so a hostile size stays a font size.
|
|
const CLIP_SIZE_PT: std::ops::RangeInclusive<f64> = 1.0..=1000.0;
|
|
/// A family name longer than this is not a family name.
|
|
const MAX_FAMILY_CHARS: usize = 64;
|
|
|
|
/// How a span looks.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct ClipStyle {
|
|
/// 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,
|
|
pub bold: bool,
|
|
pub italic: bool,
|
|
/// Fill colour as RGB in 0..=1.
|
|
pub color: [f64; 3],
|
|
}
|
|
|
|
/// A run of text in one style.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct ClipSpan {
|
|
pub text: String,
|
|
pub style: ClipStyle,
|
|
}
|
|
|
|
/// The copied text: paragraphs of spans, and whether a cap left some out.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct ClipText {
|
|
pub paragraphs: Vec<Vec<ClipSpan>>,
|
|
pub truncated: bool,
|
|
}
|
|
|
|
/// Builds a [`ClipText`] piece by piece, merging pieces of equal style into one span and
|
|
/// stopping at the caps.
|
|
#[derive(Debug)]
|
|
pub struct ClipBuilder {
|
|
out: ClipText,
|
|
/// The separator owed before the next piece in this paragraph (a space or a line break).
|
|
pending: Option<char>,
|
|
new_paragraph: bool,
|
|
spans: usize,
|
|
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::default()
|
|
}
|
|
|
|
/// A word space before the next piece (dropped at the start of a paragraph).
|
|
pub fn space(&mut self) {
|
|
self.pending.get_or_insert(' ');
|
|
}
|
|
|
|
/// A line break inside the paragraph before the next piece.
|
|
pub fn line_break(&mut self) {
|
|
self.pending = Some('\n');
|
|
}
|
|
|
|
/// The next piece starts a new paragraph.
|
|
pub fn paragraph(&mut self) {
|
|
self.new_paragraph = true;
|
|
self.pending = None;
|
|
}
|
|
|
|
/// Append `text` in `style`. Returns `false` once a cap is reached; nothing more is kept.
|
|
pub fn push(&mut self, text: &str, style: &ClipStyle) -> bool {
|
|
if self.out.truncated {
|
|
return false;
|
|
}
|
|
if text.is_empty() {
|
|
return true;
|
|
}
|
|
let sep = if self.new_paragraph { None } else { self.pending };
|
|
let need = text.len().saturating_add(sep.map_or(0, char::len_utf8));
|
|
let Some(bytes) = self.bytes.checked_add(need).filter(|b| *b <= MAX_CLIP_TEXT_BYTES) else {
|
|
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;
|
|
}
|
|
self.pending = None;
|
|
let Some(spans) = self.out.paragraphs.last_mut() else { return false };
|
|
match spans.last_mut() {
|
|
Some(last) if last.style == *style => {
|
|
last.text.extend(sep);
|
|
last.text.push_str(text);
|
|
}
|
|
last => {
|
|
// 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);
|
|
}
|
|
spans.push(ClipSpan { text: text.to_owned(), style: style.clone() });
|
|
self.spans += 1;
|
|
}
|
|
}
|
|
self.bytes = bytes;
|
|
true
|
|
}
|
|
|
|
pub fn finish(self) -> ClipText {
|
|
self.out
|
|
}
|
|
}
|
|
|
|
/// 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 name = name.split_once(',').map_or(name, |(family, _)| family);
|
|
let family = match name.rsplit_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;
|
|
for c in family.chars().take(MAX_FAMILY_CHARS) {
|
|
if prev_lower && c.is_ascii_uppercase() {
|
|
spaced.push(' ');
|
|
}
|
|
prev_lower = c.is_ascii_lowercase();
|
|
spaced.push(if c == '-' { ' ' } else { c });
|
|
}
|
|
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> {
|
|
let kept: String = family.chars().filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.')).take(MAX_FAMILY_CHARS * 2).collect();
|
|
let kept = kept.trim();
|
|
(!kept.is_empty()).then(|| kept.to_owned())
|
|
}
|
|
|
|
/// 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 ` `. 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, &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, true);
|
|
}
|
|
p.push_str(&s);
|
|
}
|
|
p.push_str(CLOSE);
|
|
out.push_str(&p);
|
|
}
|
|
(out, false)
|
|
}
|
|
|
|
/// 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();
|
|
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 {
|
|
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, space_before);
|
|
if st.italic {
|
|
body = format!("<em>{body}</em>");
|
|
}
|
|
if st.bold {
|
|
body = format!("<strong>{body}</strong>");
|
|
}
|
|
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 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, space_before: &mut bool) -> String {
|
|
let mut out = String::with_capacity(text.len());
|
|
for c in text.chars().filter(|c| clip_char(*c)) {
|
|
match c {
|
|
'\n' => out.push_str("<br>"),
|
|
' ' if *space_before => out.push_str(" "),
|
|
'&' => out.push_str("&"),
|
|
'<' => out.push_str("<"),
|
|
'>' => out.push_str(">"),
|
|
'"' => out.push_str("""),
|
|
c => out.push(c),
|
|
}
|
|
*space_before = matches!(c, ' ' | '\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Whether `c` may go into the clipboard HTML's text.
|
|
fn clip_char(c: char) -> bool {
|
|
allowed(c) && !matches!(c, '\r' | '\u{80}'..='\u{9F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
|
|
}
|
|
|
|
/// A size with at most one decimal and no trailing `.0`.
|
|
fn num(v: f64) -> String {
|
|
let r = (v * 10.0).round() / 10.0;
|
|
if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r:.1}") }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn style(size: f64, bold: bool, italic: bool, color: [f64; 3]) -> ClipStyle {
|
|
ClipStyle { families: font_family("ABCDEF+Helvetica-Bold"), size, bold, italic, color }
|
|
}
|
|
|
|
#[test]
|
|
fn heading_body_and_coloured_span_keep_their_formatting() {
|
|
let mut b = ClipBuilder::new();
|
|
let heading = style(24.0, true, false, [0.0; 3]);
|
|
let body = style(11.5, false, false, [0.0; 3]);
|
|
let red = style(11.5, false, true, [1.0, 0.0, 0.0]);
|
|
b.push("Title", &heading);
|
|
b.paragraph();
|
|
b.push("Body", &body);
|
|
b.space();
|
|
b.push("text", &body);
|
|
b.space();
|
|
b.push("red", &red);
|
|
let clip = b.finish();
|
|
assert_eq!(clip.paragraphs.len(), 2);
|
|
assert_eq!(clip.paragraphs.get(1).map(Vec::len), Some(2), "{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\
|
|
<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:11.5pt\">Body text </span>\
|
|
<span style=\"font-family:'Helvetica';font-size:11.5pt;color:#FF0000\"><em>red</em></span></p>\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn special_characters_are_escaped_and_cannot_leave_the_style() {
|
|
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());
|
|
assert_eq!(
|
|
html,
|
|
"<p style=\"margin:0\"><span style=\"font-family:'Evilstylescript'\"><a href="x">&</a> two<br>line</span></p>\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
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()).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"), (-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());
|
|
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() {
|
|
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("Segoe-UI-Bold"), ["Segoe UI", "Segoe-UI"], "the style is the last part");
|
|
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\"> lead a <strong> b<br></strong> 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()), (String::new(), false));
|
|
let mut b = ClipBuilder::new();
|
|
b.space();
|
|
assert!(b.push("", &ClipStyle::default()));
|
|
assert!(b.finish().paragraphs.is_empty(), "nothing pushed, nothing kept");
|
|
|
|
// Alternating styles: the span cap stops it.
|
|
let (a, c) = (style(10.0, false, false, [0.0; 3]), style(10.0, true, false, [0.0; 3]));
|
|
let mut b = ClipBuilder::new();
|
|
let mut kept = 0usize;
|
|
for i in 0..MAX_CLIP_SPANS + 10 {
|
|
if b.push("x", if i % 2 == 0 { &a } else { &c }) {
|
|
kept += 1;
|
|
}
|
|
}
|
|
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_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, 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());
|
|
}
|
|
}
|