mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 04:51:37 +00:00
Co-authored-by: jonathanwxh-cell <jonathanwxh-cell@users.noreply.github.com>
This commit is contained in:
1 parent
7fa6f10720
commit
ab502255cb
6 files changed
+308
-56
No files matched your search
+133
-1
@@ -11026,8 +11026,140 @@ fn carriage_returns_break_rows_on_the_styled_path() {
|
||||
fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() {
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET");
|
||||
assert_eq!(text::replace_block(&mut doc, 0, 0, "ab ba ab").unwrap().substituted, None);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
|
||||
let reopened = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&reopened, 0)).into_owned();
|
||||
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
|
||||
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, "ab ba ab");
|
||||
}
|
||||
|
||||
/// Spaces drawn as TJ gaps in a subset without a space glyph are wrapped in `/ActualText`, so
|
||||
/// repeated spaces and leading indents read back through `text_lines`, `editor_text`, and text
|
||||
/// extraction, and survive a second edit without shifting the line or miscalibrating its scale
|
||||
/// (#4229).
|
||||
#[test]
|
||||
fn spaces_and_indents_in_a_subset_without_a_space_glyph_round_trip_across_edits() {
|
||||
for paragraph in [true, false] {
|
||||
for typed in [" ab ba", "ab ba ab", " ab ba ab "] {
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET");
|
||||
let edit = if paragraph { text::replace_block(&mut doc, 0, 0, typed) } else { text::replace_line(&mut doc, 0, 0, typed) };
|
||||
assert_eq!(edit.unwrap().substituted, None, "{typed:?}");
|
||||
let mut doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.len(), 1, "{typed:?}: {lines:?}");
|
||||
assert_eq!(lines[0].text, typed, "{typed:?}");
|
||||
assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{typed:?}: {:?}", lines[0].rect);
|
||||
let blocks = text::text_blocks(&doc, 0).unwrap();
|
||||
assert_eq!(blocks[0].editor_text, typed, "{typed:?}");
|
||||
|
||||
// Re-edit the reopened document and verify the origin, gap count, and scale stay exact.
|
||||
let next = " ba ab";
|
||||
let edit2 = if paragraph { text::replace_block(&mut doc, 0, 0, next) } else { text::replace_line(&mut doc, 0, 0, next) };
|
||||
assert_eq!(edit2.unwrap().substituted, None, "{typed:?}");
|
||||
let doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines[0].text, next, "{typed:?}");
|
||||
assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{typed:?}: {:?}", lines[0].rect);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
|
||||
}
|
||||
}
|
||||
|
||||
// Styled multi-row path: untouched calibration and row breaks keep 100 Tz and every space.
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
assert_eq!(text::replace_block(&mut doc, 0, 0, " ab ba\nba ab").unwrap().substituted, None);
|
||||
let mut doc = reopen(&doc);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " ab ba\nba ab");
|
||||
assert_eq!(text::replace_block(&mut doc, 0, 0, " ab ba\nba ab").unwrap().substituted, None);
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
let scales: Vec<&str> = content.lines().filter(|l| l.ends_with(" Tz")).collect();
|
||||
assert!(scales.iter().all(|l| *l == "100 Tz"), "{scales:?}: {content}");
|
||||
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert_eq!(lines, [" ab ba", "ba ab"]);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " ab ba\nba ab");
|
||||
|
||||
// Two subsets of the same face (/F2 holds 'a' only, /F3 holds 'b' only, neither has ' '):
|
||||
// spaces adopt their neighbour's subset on the row so no space is stranded in a glyph-less run.
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (a) Tj /F1 12 Tf ( ) Tj /F3 12 Tf (b) Tj ET");
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |f| {
|
||||
f.set(b"FirstChar".to_vec(), Object::Int(97));
|
||||
f.set(b"LastChar".to_vec(), Object::Int(97));
|
||||
f.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(500)]));
|
||||
})
|
||||
.unwrap();
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(7, 0), |f| {
|
||||
f.set(b"FirstChar".to_vec(), Object::Int(98));
|
||||
f.set(b"LastChar".to_vec(), Object::Int(98));
|
||||
f.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(520)]));
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(text::replace_block(&mut doc, 0, 0, " b a \n a b ").unwrap().substituted, None);
|
||||
let doc = reopen(&doc);
|
||||
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert_eq!(lines, [" b a ", " a b "]);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " b a \n a b ");
|
||||
|
||||
// A RangeStyle covering only spaces in a subset without a space glyph joins an adjacent
|
||||
// glyph-bearing segment so its space count survives save/reopen.
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
let style = text::BlockStyle {
|
||||
ranges: vec![text::RangeStyle {
|
||||
range: 2..5,
|
||||
family: None,
|
||||
resource: None,
|
||||
size: None,
|
||||
color: Some([1.0, 0.0, 0.0]),
|
||||
underline: None,
|
||||
char_spacing: None,
|
||||
scale: None,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(text::rewrite_block(&mut doc, 0, 0, Some("ab ba"), &style).unwrap().substituted, None);
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "ab ba");
|
||||
}
|
||||
|
||||
/// A space that joins a neighbour's subset for `/ActualText` keeps the underline of its own
|
||||
/// style, not the neighbour's, and still reads back as that space.
|
||||
#[test]
|
||||
fn a_reassigned_subset_space_keeps_its_own_underline() {
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
let style = text::BlockStyle {
|
||||
ranges: vec![text::RangeStyle {
|
||||
range: 2..5,
|
||||
family: None,
|
||||
resource: None,
|
||||
size: None,
|
||||
color: None,
|
||||
underline: Some(true),
|
||||
char_spacing: None,
|
||||
scale: None,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(text::rewrite_block(&mut doc, 0, 0, Some("ab ba"), &style).unwrap().substituted, None);
|
||||
let doc = reopen(&doc);
|
||||
let line = text::text_lines(&doc, 0).unwrap().into_iter().next().expect("a line");
|
||||
assert_eq!(line.text, "ab ba");
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
let mut widths = Vec::new();
|
||||
let mut from = None;
|
||||
for row in content.lines() {
|
||||
if let Some(rest) = row.strip_suffix(" m") {
|
||||
from = rest.split_whitespace().next().and_then(|s| s.parse::<f64>().ok());
|
||||
} else if let Some(rest) = row.strip_suffix(" l")
|
||||
&& let (Some(x0), Some(x1)) = (from.take(), rest.split_whitespace().next().and_then(|s| s.parse::<f64>().ok()))
|
||||
{
|
||||
widths.push((x1 - x0).abs());
|
||||
}
|
||||
}
|
||||
let text_width = line.rect[2] - line.rect[0];
|
||||
assert!(widths.iter().any(|w| *w > 1.0), "the spaces lost their underline: {widths:?}\n{content}");
|
||||
assert!(
|
||||
widths.iter().all(|w| *w < text_width * 0.6),
|
||||
"the space underline covered the neighbouring glyphs ({text_width}): {widths:?}\n{content}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #6131: a subset that writes width 0 for its unused space (pdfTeX does) has no space glyph. A word added to
|
||||
|
||||
@@ -720,6 +720,13 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64,
|
||||
out.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
}
|
||||
}
|
||||
if out.iter().any(|op| op.is("Tj")) {
|
||||
let logical = crate::bidi::to_logical(&if metrics.visual_order { crate::indic::to_logical(text) } else { text.to_owned() });
|
||||
let mut properties = Dict::new();
|
||||
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&logical)));
|
||||
out.insert(0, Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
|
||||
out.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -567,32 +567,34 @@ impl MarkedContent {
|
||||
self.layers.pop();
|
||||
if let Some(Some((start, open, logical))) = self.actual_text.pop() {
|
||||
let tail = shown.split_off(start.min(shown.len()));
|
||||
let mut items = tail.into_iter();
|
||||
if let Some(mut first) = items.next() {
|
||||
for item in items {
|
||||
first.glyph_count = first.glyph_count.saturating_add(item.glyph_count);
|
||||
first.extra_ops.push(item.op);
|
||||
first.extra_ops.extend(item.extra_ops);
|
||||
first.rect = [
|
||||
first.rect[0].min(item.rect[0]),
|
||||
first.rect[1].min(item.rect[1]),
|
||||
first.rect[2].max(item.rect[2]),
|
||||
first.rect[3].max(item.rect[3]),
|
||||
];
|
||||
first.frame = union(first.frame, item.frame);
|
||||
first.ink.extend(item.ink);
|
||||
if tail.iter().any(|s| s.glyph_count > 0) {
|
||||
let mut items = tail.into_iter();
|
||||
if let Some(mut first) = items.next() {
|
||||
for item in items {
|
||||
first.glyph_count = first.glyph_count.saturating_add(item.glyph_count);
|
||||
first.extra_ops.push(item.op);
|
||||
first.extra_ops.extend(item.extra_ops);
|
||||
first.rect = [
|
||||
first.rect[0].min(item.rect[0]),
|
||||
first.rect[1].min(item.rect[1]),
|
||||
first.rect[2].max(item.rect[2]),
|
||||
first.rect[3].max(item.rect[3]),
|
||||
];
|
||||
first.frame = union(first.frame, item.frame);
|
||||
first.ink.extend(item.ink);
|
||||
}
|
||||
first.extra_ops.extend([open, index]);
|
||||
// Only right-to-left ActualText is already in typing order and must stay out of
|
||||
// bidi recovery. Word and Chrome also wrap Latin ligatures mid-word.
|
||||
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
|
||||
first.actual_text = true;
|
||||
first.text = logical;
|
||||
first.start_x = first.rect[0];
|
||||
first.end_x = first.rect[2];
|
||||
first.end_y = first.baseline;
|
||||
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
|
||||
shown.push(first);
|
||||
}
|
||||
first.extra_ops.extend([open, index]);
|
||||
// Only right-to-left ActualText is already in typing order and must stay out of
|
||||
// bidi recovery. Word and Chrome also wrap Latin ligatures mid-word.
|
||||
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
|
||||
first.actual_text = true;
|
||||
first.text = logical;
|
||||
first.start_x = first.rect[0];
|
||||
first.end_x = first.rect[2];
|
||||
first.end_y = first.baseline;
|
||||
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
|
||||
shown.push(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -761,8 +763,11 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
|
||||
.collect();
|
||||
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
|
||||
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
|
||||
// It must not create a phantom editable line or enlarge its neighbours' boxes.
|
||||
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
|
||||
// Outside an `/ActualText` replacement it must not create a phantom editable
|
||||
// line or enlarge its neighbours' boxes; inside one (such as a space gap in a
|
||||
// subset font without a space glyph, #4229) it is part of the span's box and
|
||||
// operators when the span also paints glyphs.
|
||||
if !marked.actual_text.iter().any(Option::is_some) && !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
|
||||
return Ok(());
|
||||
}
|
||||
out.push(Shown {
|
||||
@@ -1006,6 +1011,7 @@ pub(super) fn interpret(
|
||||
carry.stack = stack;
|
||||
carry.marked = marked.regions;
|
||||
carry.layers = marked.layers;
|
||||
out.retain(|s| s.glyph_count > 0 || s.actual_text);
|
||||
positioned_tracking(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
@@ -649,12 +649,17 @@ impl LineWriter<'_> {
|
||||
}
|
||||
// Each wrapped line in typing order; drawn left to right (right-to-left runs reversed).
|
||||
let visual = crate::bidi::to_visual(shown);
|
||||
// Stretched without word spacing: shown up to and including each space, then moved on.
|
||||
let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] };
|
||||
for piece in pieces {
|
||||
self.show_piece(piece, line, tw, cursor, ops)?;
|
||||
if stretch > 0.0 && piece.ends_with(' ') {
|
||||
ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])]));
|
||||
if let Some(metrics) = self.source.filter(|m| m.encode(" ").is_none()) {
|
||||
let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual };
|
||||
ops.extend(source_show(metrics, &indic_line, self.size, self.space, self.char_spacing, tw + stretch)?);
|
||||
} else {
|
||||
// Stretched without word spacing: shown up to and including each space, then moved on.
|
||||
let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] };
|
||||
for piece in pieces {
|
||||
self.show_piece(piece, line, tw, cursor, ops)?;
|
||||
if stretch > 0.0 && piece.ends_with(' ') {
|
||||
ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])]));
|
||||
}
|
||||
}
|
||||
}
|
||||
let tw = tw + stretch;
|
||||
|
||||
@@ -354,6 +354,17 @@ struct StyledLines<'a> {
|
||||
}
|
||||
|
||||
type StyledChar = (char, usize, String);
|
||||
|
||||
/// End of a contiguous run sharing both its style and font resource.
|
||||
fn styled_run_end(items: &[StyledChar], start: usize) -> usize {
|
||||
let Some(first) = items.get(start) else { return start };
|
||||
let mut end = start + 1;
|
||||
while items.get(end).is_some_and(|next| next.1 == first.1 && next.2 == first.2) {
|
||||
end += 1;
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
type StyledRows = Vec<Vec<StyledChar>>;
|
||||
type RowRanges = Vec<std::ops::Range<usize>>;
|
||||
enum ShapedFont {
|
||||
@@ -370,6 +381,7 @@ type ShapedRunCache = HashMap<(usize, usize), ShapedText>;
|
||||
struct ShapedRows {
|
||||
runs: ShapedRunCache,
|
||||
advances: HashMap<(usize, usize), f64>,
|
||||
char_advances: HashMap<(usize, usize), Vec<f64>>,
|
||||
}
|
||||
|
||||
fn prepare_shaped_rows(
|
||||
@@ -382,15 +394,15 @@ fn prepare_shaped_rows(
|
||||
) -> Result<ShapedRows, EditError> {
|
||||
let mut runs = HashMap::new();
|
||||
let mut advances = HashMap::new();
|
||||
let mut char_advances = HashMap::new();
|
||||
for (row_index, row) in rows.iter().enumerate().filter(|(index, _)| !kept.get(*index).copied().unwrap_or(false)) {
|
||||
let mut at = 0;
|
||||
while let Some(item) = row.get(at) {
|
||||
let mut end = at + 1;
|
||||
while row.get(end).is_some_and(|next| next.1 == item.1 && next.2 == item.2) {
|
||||
end += 1;
|
||||
}
|
||||
let end = styled_run_end(row, at);
|
||||
let segment = row.get(at..end).unwrap_or_default();
|
||||
advances.insert((row_index, at), segment.iter().map(&advance).sum::<f64>());
|
||||
let widths: Vec<f64> = segment.iter().map(&advance).collect();
|
||||
advances.insert((row_index, at), widths.iter().copied().sum());
|
||||
char_advances.insert((row_index, at), widths);
|
||||
let text: String = segment.iter().map(|character| character.0).collect();
|
||||
if let Some(shaped) = shape_source(doc, fonts, thai, &item.2, &text)? {
|
||||
runs.insert((row_index, at), shaped);
|
||||
@@ -398,7 +410,7 @@ fn prepare_shaped_rows(
|
||||
at = end;
|
||||
}
|
||||
}
|
||||
Ok(ShapedRows { runs, advances })
|
||||
Ok(ShapedRows { runs, advances, char_advances })
|
||||
}
|
||||
|
||||
type ShapedResources = HashMap<(usize, usize), Vec<(String, Vec<u8>)>>;
|
||||
@@ -1297,6 +1309,70 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range<usize>
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn segment_underline_runs(
|
||||
widths: &[f64],
|
||||
segment_width: f64,
|
||||
x: f64,
|
||||
default_style: usize,
|
||||
row_styles: Option<&[Option<usize>]>,
|
||||
pos: usize,
|
||||
) -> Vec<(usize, f64, f64)> {
|
||||
let width_sum = widths.iter().copied().sum::<f64>();
|
||||
let width_scale = if width_sum > 1e-6 { segment_width / width_sum } else { 0.0 };
|
||||
let mut cursor = x;
|
||||
let mut runs: Vec<(usize, f64, f64)> = Vec::new();
|
||||
for (offset, width) in widths.iter().enumerate() {
|
||||
let style_index = row_styles.and_then(|styles| styles.get(pos.saturating_add(offset))).and_then(|style| *style).unwrap_or(default_style);
|
||||
let next = cursor + *width * width_scale;
|
||||
match runs.last_mut() {
|
||||
Some((prev, _, to)) if *prev == style_index => *to = next,
|
||||
_ => runs.push((style_index, cursor, next)),
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// Keep spaces in a subset without a space glyph attached to an adjacent glyph-bearing segment on
|
||||
/// the same row so `source_show` wraps them in one `/ActualText` span (#4229).
|
||||
///
|
||||
/// The returned slot is the space's style index from before that reassignment, where the
|
||||
/// grouping key changed. Underlines stay with that style; the row itself keeps the neighbour's
|
||||
/// key so the space is not a group of its own.
|
||||
fn assign_subset_space_fonts(row: &mut [(char, usize, String)], metrics: &std::collections::BTreeMap<String, Metrics>) -> Vec<Option<usize>> {
|
||||
let mut right_any = vec![None; row.len()];
|
||||
let mut next_any: Option<(usize, String)> = None;
|
||||
for (i, item) in row.iter().enumerate().rev() {
|
||||
if item.0 != ' ' {
|
||||
next_any = Some((item.1, item.2.clone()));
|
||||
} else if let Some(slot) = right_any.get_mut(i) {
|
||||
*slot = next_any.clone();
|
||||
}
|
||||
}
|
||||
let mut prev_any: Option<(usize, String)> = None;
|
||||
let mut underline_style = vec![None; row.len()];
|
||||
for (i, item) in row.iter_mut().enumerate() {
|
||||
if item.0 != ' ' {
|
||||
prev_any = Some((item.1, item.2.clone()));
|
||||
continue;
|
||||
}
|
||||
if metrics.get(&item.2).is_none_or(|m| m.encode(" ").is_some()) {
|
||||
continue;
|
||||
}
|
||||
if let Some((style, font)) = prev_any.as_ref().or_else(|| right_any.get(i).and_then(Option::as_ref)) {
|
||||
let original = item.1;
|
||||
item.1 = *style;
|
||||
item.2 = font.clone();
|
||||
if let Some(slot) = underline_style.get_mut(i)
|
||||
&& item.1 != original
|
||||
{
|
||||
*slot = Some(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
underline_style
|
||||
}
|
||||
|
||||
/// Operator index of the paragraph's first text object, and of the next line's text object
|
||||
/// when one follows.
|
||||
fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Option<usize>)> {
|
||||
@@ -1584,10 +1660,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
let mut width = 0.0;
|
||||
let mut at = 0;
|
||||
while let Some(item) = items.get(at) {
|
||||
let mut end = at + 1;
|
||||
while items.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) {
|
||||
end += 1;
|
||||
}
|
||||
let end = styled_run_end(items, at);
|
||||
let segment = items.get(at..end).unwrap_or_default();
|
||||
let string: String = segment.iter().map(|v| v.0).collect();
|
||||
if let Some(shaped) = shape_source(doc, &fonts, &thai, &item.2, &string)? {
|
||||
@@ -1612,7 +1685,8 @@ pub(crate) fn rewrite_styled_block(
|
||||
}
|
||||
Ok(width)
|
||||
};
|
||||
let (rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?;
|
||||
let (mut rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?;
|
||||
let space_styles: Vec<_> = rows.iter_mut().map(|row| assign_subset_space_fonts(row, &metrics)).collect();
|
||||
let streams = content_streams(doc, &p.dict)?;
|
||||
let pieces = crate::text::joined(&streams);
|
||||
let ops = pieces.parse().ops;
|
||||
@@ -1643,7 +1717,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
validate_contextual_rows(&rows, &kept)?;
|
||||
let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::<Result<_, _>>()?;
|
||||
let shaped = prepare_shaped_rows(doc, &fonts, &thai, &rows, &kept, advance)?;
|
||||
let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped;
|
||||
let ShapedRows { runs: shaped_runs, advances: segment_advances, char_advances } = shaped;
|
||||
let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?;
|
||||
let n = pdfcraft_content::num;
|
||||
let mut block_ops = vec![Op::new("q", vec![])];
|
||||
@@ -1720,10 +1794,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
while pos < row.len() {
|
||||
let Some(item) = row.get(pos) else { break };
|
||||
let source = lines.get(item.1).unwrap_or(first);
|
||||
let mut end = pos + 1;
|
||||
while row.get(end).is_some_and(|other| other.1 == item.1 && other.2 == item.2) {
|
||||
end += 1;
|
||||
}
|
||||
let end = styled_run_end(row, pos);
|
||||
let segment = row.get(pos..end).unwrap_or_default();
|
||||
let string: String = segment.iter().map(|c| c.0).collect();
|
||||
let m = metrics.get(&item.2).ok_or_else(|| EditError::Invalid("the paragraph font is missing".into()))?;
|
||||
@@ -1779,11 +1850,20 @@ pub(crate) fn rewrite_styled_block(
|
||||
)?);
|
||||
block_ops.push(Op::new("ET", vec![]));
|
||||
}
|
||||
if underlines.get(&item.1).copied().or(style.underline) == Some(true) {
|
||||
let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color);
|
||||
let underline_size = size_of(source, item.1) * source.origin.k;
|
||||
let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
|
||||
let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
|
||||
// A space reassigned onto a neighbour keeps that neighbour's grouping key, and its
|
||||
// own style for the underline. Stroke each run of one underline style separately.
|
||||
let widths = char_advances.get(&(row_index, pos)).map(Vec::as_slice).unwrap_or(&[]);
|
||||
for (style_index, from, to) in
|
||||
segment_underline_runs(widths, segment_width, x, item.1, space_styles.get(row_index).map(Vec::as_slice), pos)
|
||||
{
|
||||
let source = lines.get(style_index).unwrap_or(first);
|
||||
if underlines.get(&style_index).copied().or(style.underline) != Some(true) || to <= from {
|
||||
continue;
|
||||
}
|
||||
let [r, g, b] = overrides.get(&style_index).and_then(|r| r.color).or(style.color).unwrap_or(source.color);
|
||||
let underline_size = size_of(source, style_index) * source.origin.k;
|
||||
let (x0, y0) = back.apply(from - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
|
||||
let (x1, y1) = back.apply(to - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
|
||||
block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)]));
|
||||
block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))]));
|
||||
block_ops.push(Op::new("m", vec![n(x0), n(y0)]));
|
||||
@@ -1819,3 +1899,19 @@ pub(crate) fn rewrite_styled_block(
|
||||
crate::draw_on_top(doc, &p, o.ctm, &on_top)?;
|
||||
Ok(LineEdit { substituted })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::styled_run_end;
|
||||
|
||||
#[test]
|
||||
fn styled_runs_stop_at_style_or_font_changes() {
|
||||
let items = vec![('a', 0, "F1".into()), (' ', 0, "F1".into()), ('b', 1, "F1".into()), ('c', 1, "F2".into())];
|
||||
assert_eq!(styled_run_end(&items, 0), 2);
|
||||
assert_eq!(styled_run_end(&items, 1), 2);
|
||||
assert_eq!(styled_run_end(&items, 2), 3);
|
||||
assert_eq!(styled_run_end(&items, 3), 4);
|
||||
assert_eq!(styled_run_end(&items, 4), 4);
|
||||
assert_eq!(styled_run_end(&[], 0), 0);
|
||||
}
|
||||
}
|
||||
@@ -508,7 +508,13 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
let tight_cluster = (loose_sign(pt) || loose_sign(ct)) && gap < h * 0.6;
|
||||
let run_start = sc.breaks.binary_search(&c).is_ok();
|
||||
if let Some(space) = spaces.get_mut(w + 1) {
|
||||
*space = (gap > threshold || run_start) && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty();
|
||||
*space = (gap > threshold || run_start)
|
||||
&& !cjk
|
||||
&& !tight_cluster
|
||||
&& !pt.trim().is_empty()
|
||||
&& !ct.trim().is_empty()
|
||||
&& !pt.ends_with(char::is_whitespace)
|
||||
&& !ct.starts_with(char::is_whitespace);
|
||||
}
|
||||
}
|
||||
// Right-to-left spans are reversed (visual → logical) with their gaps, and only then is a second copy made.
|
||||
|
||||
Reference in new issue
Block a user