mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 23:32:08 +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
390 lines
19 KiB
Rust
390 lines
19 KiB
Rust
//! Copy with formatting: the selected glyphs of a page as paragraphs of styled spans and an
|
|
//! HTML fragment ([`pdfcraft_export::clip_html`]) for the clipboard, next to the plain text.
|
|
//!
|
|
//! Glyphs come from the text layer (view space); style comes from the page's reading blocks,
|
|
//! which never mix styles, mapped into view space. A glyph takes the style of the block its
|
|
//! centre lies in; a glyph in no block is copied unstyled.
|
|
|
|
use pdfcraft_export::{ClipBuilder, ClipStyle, ClipText, clip_html};
|
|
use pdfcraft_render::{PageInfo, PageText};
|
|
|
|
use crate::EditError;
|
|
use crate::compare::{display_unit, weight_and_slant};
|
|
|
|
/// Block tests spent matching glyphs to blocks for one copy; past it the rest is unstyled.
|
|
const MAX_MATCH_STEPS: usize = 20_000_000;
|
|
/// How far outside a block's box (points) a glyph centre still belongs to it.
|
|
const SLACK: f32 = 0.5;
|
|
|
|
/// Selected text with its formatting.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct RichCopy {
|
|
/// The paragraphs and spans.
|
|
pub text: ClipText,
|
|
/// The HTML fragment for the clipboard (empty when nothing was selected).
|
|
pub html: String,
|
|
/// 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 {
|
|
rect: [f32; 4],
|
|
style: ClipStyle,
|
|
}
|
|
|
|
impl crate::Document {
|
|
/// Selected `glyphs` of `page` (indices into `text`, in copy order) with their formatting.
|
|
/// Refused when the document does not allow copying.
|
|
pub fn rich_copy(&self, page: usize, text: &PageText, glyphs: &[usize]) -> Result<RichCopy, EditError> {
|
|
self.rich_copy_within(page, text, glyphs, MAX_MATCH_STEPS)
|
|
}
|
|
|
|
/// [`Self::rich_copy`] with `steps` block tests to match glyphs to blocks.
|
|
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, 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 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, prev_rect, hyphen)) = prev {
|
|
if line != prev_line {
|
|
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 {
|
|
b.paragraph();
|
|
}
|
|
} else if text.space_before.get(i).copied().unwrap_or(false) {
|
|
b.space();
|
|
}
|
|
}
|
|
let style = found.and_then(|k| blocks.get(k)).map(|s| &s.style);
|
|
if !b.push(&g.text, style.unwrap_or(&ClipStyle::default())) {
|
|
break;
|
|
}
|
|
prev = Some((line, found, g.rect, g.text.ends_with('-')));
|
|
}
|
|
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(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
|
|
.blocks
|
|
.into_iter()
|
|
.filter(|b| !b.text.trim().is_empty())
|
|
.map(|b| {
|
|
let (bold, italic) = weight_and_slant(&b.base_font);
|
|
let style = ClipStyle {
|
|
families: pdfcraft_export::font_family(&b.base_font),
|
|
size: b.size * unit,
|
|
bold: b.bold || bold,
|
|
italic: b.italic || italic,
|
|
color: b.color,
|
|
};
|
|
StyledBlock { rect: view_rect(info, b.rect), style }
|
|
})
|
|
.collect();
|
|
(blocks, reading.truncated)
|
|
}
|
|
}
|
|
|
|
/// A user-space box as a view-space box (points from the displayed page's top-left, y down).
|
|
fn view_rect(info: &PageInfo, r: [f64; 4]) -> [f32; 4] {
|
|
let a = info.user_to_view(r[0] as f32, r[1] as f32);
|
|
let b = info.user_to_view(r[2] as f32, r[3] as f32);
|
|
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
|
|
}
|
|
|
|
/// 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 };
|
|
let diff = (prev.size - next.size).abs();
|
|
if prev == next || diff.is_nan() || diff > 0.01 {
|
|
return false;
|
|
}
|
|
let height = (prev_rect[3] - prev_rect[1]).max(rect[3] - rect[1]);
|
|
// The next line starts just below the last one; a line far above it is another column.
|
|
let gap = rect[1] - prev_rect[3];
|
|
height > 0.0 && gap.is_finite() && gap >= -height / 2.0 && 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;
|
|
let area = |r: &[f32; 4]| (r[2] - r[0]) * (r[3] - r[1]);
|
|
if let Some(k) = hint
|
|
&& *steps > 0
|
|
{
|
|
*steps -= 1;
|
|
if blocks.get(k).is_some_and(|b| holds(&b.rect)) {
|
|
return Some(Some(k));
|
|
}
|
|
}
|
|
let mut best: Option<(usize, f32)> = None;
|
|
for (k, b) in blocks.iter().enumerate() {
|
|
if *steps == 0 {
|
|
return None;
|
|
}
|
|
*steps -= 1;
|
|
if holds(&b.rect) && best.is_none_or(|(_, a)| area(&b.rect) < a) {
|
|
best = Some((k, area(&b.rect)));
|
|
}
|
|
}
|
|
Some(best.map(|(k, _)| k))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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
|
|
/// word on the body's first line.
|
|
fn styled_page() -> Vec<u8> {
|
|
let content = b"BT /F1 24 Tf 20 250 Td (Title) Tj ET \
|
|
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] >>")
|
|
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>")
|
|
.stream("", content)
|
|
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>")
|
|
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
|
.build()
|
|
}
|
|
|
|
fn page_text(s: &Session, id: crate::DocId) -> Arc<pdfcraft_render::PageText> {
|
|
let doc = s.get(id).unwrap();
|
|
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
|
|
let request = pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() };
|
|
r.render(request).text.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn copy_keeps_headings_body_and_colour_as_html() {
|
|
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();
|
|
let copy = s.get(id).unwrap().rich_copy(0, &text, &all).unwrap();
|
|
let html = ©.html;
|
|
assert!(!copy.styles_incomplete);
|
|
assert!(
|
|
html.starts_with("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>Title</strong></span></p>\n"),
|
|
"{html}"
|
|
);
|
|
assert!(html.contains("font-size:12pt\">Body text "), "{html}");
|
|
assert!(html.contains("font-size:12pt;color:#FF0000\">red</span>"), "{html}");
|
|
assert!(html.contains("a<b & c"), "{html}");
|
|
assert!(!html.contains("<b &"), "{html}");
|
|
assert_eq!(copy.text.paragraphs.len(), html.matches("<p ").count());
|
|
assert!(copy.text.paragraphs.len() >= 2, "{html}");
|
|
// The heading and the body are separate paragraphs; the red word shares the body's.
|
|
let body = copy.text.paragraphs.iter().find(|p| p.iter().any(|s| s.text.contains("Body"))).unwrap();
|
|
assert!(body.iter().any(|s| s.text == "red" && s.style.color == [1.0, 0.0, 0.0]), "{body:?}");
|
|
|
|
// A part of the selection: only those glyphs.
|
|
let part = s.get(id).unwrap().rich_copy(0, &text, &all[..5]).unwrap();
|
|
assert_eq!(part.text.paragraphs.len(), 1);
|
|
assert!(part.html.contains("<strong>Title</strong>"), "{}", part.html);
|
|
// Nothing selected, and indices past the text: an empty copy, not a panic.
|
|
let none = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap();
|
|
assert!(none.html.is_empty() && none.text.paragraphs.is_empty());
|
|
let past = s.get(id).unwrap().rich_copy(7, &text, &[usize::MAX, text.glyphs.len()]).unwrap();
|
|
assert!(past.html.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn running_out_of_match_steps_copies_the_rest_unstyled() {
|
|
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();
|
|
let doc = s.get(id).unwrap();
|
|
let full = doc.rich_copy(0, &text, &all).unwrap();
|
|
// Enough for the first glyphs only: the text is all there, the rest has no style.
|
|
let short = doc.rich_copy_within(0, &text, &all, 8).unwrap();
|
|
assert!(short.styles_incomplete);
|
|
let plain = |c: &super::RichCopy| c.text.paragraphs.iter().flatten().map(|s| s.text.clone()).collect::<String>();
|
|
assert_eq!(plain(&short).replace([' ', '\n'], ""), plain(&full).replace([' ', '\n'], ""));
|
|
let last = short.text.paragraphs.last().and_then(|p| p.last()).unwrap();
|
|
assert_eq!(last.style, pdfcraft_export::ClipStyle::default(), "{last:?}");
|
|
assert!(short.html.starts_with("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>T"), "{}", short.html);
|
|
// No steps at all: nothing styled, still no panic.
|
|
let none = doc.rich_copy_within(0, &text, &all, 0).unwrap();
|
|
assert!(none.styles_incomplete && !none.html.contains("font-size"), "{}", none.html);
|
|
// A page without blocks to match needs no steps.
|
|
let page_less = doc.rich_copy_within(5, &text, &all, 0).unwrap();
|
|
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_style_change_into_the_next_column_starts_a_paragraph() {
|
|
let content = b"BT /F2 12 Tf 20 200 Td (left top) Tj ET \
|
|
BT /F2 12 Tf 20 186 Td (left end) Tj ET \
|
|
BT /F1 12 Tf 170 200 Td (Right head) Tj ET \
|
|
BT /F2 12 Tf 170 186 Td (right body) Tj ET";
|
|
let mut s = Session::new();
|
|
let id = s.open("columns.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("Right")), "{paragraphs:?}");
|
|
assert!(!paragraphs.iter().any(|p| p.contains("left end") && p.contains("Right")), "{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();
|
|
cos.set_encryption(&pdfcraft_cos::NewEncryption {
|
|
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
|
user_password: "",
|
|
owner_password: "owner",
|
|
permissions: 0b0100, // print only
|
|
encrypt_metadata: true,
|
|
seed: [7; 32],
|
|
})
|
|
.unwrap();
|
|
let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap());
|
|
let mut s = Session::new();
|
|
let id = s.open("locked.pdf", None, bytes, None).unwrap();
|
|
let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new() };
|
|
let err = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap_err();
|
|
assert!(matches!(err, crate::EditError::NotPermitted(_)), "{err:?}");
|
|
}
|
|
}
|