mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
fix: address review feedback on arrange, image flips, rotate knob, and format panel
- [P1] Arrange: track and replay inherited non-ExtGState drawing state (colors, line widths, etc.) across q/Q scopes for stencil and form artwork - [P2] Flips: route ImageEdit::Flip through coordinate-aware transform logic to swap axes when rotated at odd quarter turns (90°/270°) - [P2] Rotation knob: measure clockwise angle delta from drag start relative to the rotated image center to prevent reapplying existing angle - [P2] Format panel: connect superscript/subscript controls to line editor and content draft selections, and update parity evidence
This commit is contained in:
+194
-1
@@ -828,6 +828,187 @@ impl ExtendedGStateTracker {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug, PartialEq)]
|
||||
struct DrawingState {
|
||||
non_stroking_color: Option<Vec<Op>>,
|
||||
stroking_color: Option<Vec<Op>>,
|
||||
line_width: Option<Op>,
|
||||
line_cap: Option<Op>,
|
||||
line_join: Option<Op>,
|
||||
miter_limit: Option<Op>,
|
||||
dash_pattern: Option<Op>,
|
||||
rendering_intent: Option<Op>,
|
||||
flatness: Option<Op>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
struct DrawingStateTracker {
|
||||
stack: Vec<DrawingState>,
|
||||
current: DrawingState,
|
||||
}
|
||||
|
||||
impl DrawingStateTracker {
|
||||
fn step(&mut self, op: &Op) -> Result<(), EditError> {
|
||||
match op.op.as_slice() {
|
||||
b"q" => {
|
||||
if self.stack.len() >= 4096 {
|
||||
return Err(EditError::Invalid("the image graphics state is too deeply nested".into()));
|
||||
}
|
||||
self.stack.push(self.current.clone());
|
||||
}
|
||||
b"Q" => {
|
||||
if let Some(prev) = self.stack.pop() {
|
||||
self.current = prev;
|
||||
}
|
||||
}
|
||||
b"g" | b"rg" | b"k" => {
|
||||
self.current.non_stroking_color = Some(vec![op.clone()]);
|
||||
}
|
||||
b"cs" => {
|
||||
self.current.non_stroking_color = Some(vec![op.clone()]);
|
||||
}
|
||||
b"sc" | b"scn" => {
|
||||
if let Some(ref mut ops) = self.current.non_stroking_color {
|
||||
if ops.first().is_some_and(|o| o.is("cs")) {
|
||||
ops.truncate(1);
|
||||
ops.push(op.clone());
|
||||
} else {
|
||||
*ops = vec![op.clone()];
|
||||
}
|
||||
} else {
|
||||
self.current.non_stroking_color = Some(vec![op.clone()]);
|
||||
}
|
||||
}
|
||||
b"G" | b"RG" | b"K" => {
|
||||
self.current.stroking_color = Some(vec![op.clone()]);
|
||||
}
|
||||
b"CS" => {
|
||||
self.current.stroking_color = Some(vec![op.clone()]);
|
||||
}
|
||||
b"SC" | b"SCN" => {
|
||||
if let Some(ref mut ops) = self.current.stroking_color {
|
||||
if ops.first().is_some_and(|o| o.is("CS")) {
|
||||
ops.truncate(1);
|
||||
ops.push(op.clone());
|
||||
} else {
|
||||
*ops = vec![op.clone()];
|
||||
}
|
||||
} else {
|
||||
self.current.stroking_color = Some(vec![op.clone()]);
|
||||
}
|
||||
}
|
||||
b"w" => {
|
||||
self.current.line_width = Some(op.clone());
|
||||
}
|
||||
b"J" => {
|
||||
self.current.line_cap = Some(op.clone());
|
||||
}
|
||||
b"j" => {
|
||||
self.current.line_join = Some(op.clone());
|
||||
}
|
||||
b"M" => {
|
||||
self.current.miter_limit = Some(op.clone());
|
||||
}
|
||||
b"d" => {
|
||||
self.current.dash_pattern = Some(op.clone());
|
||||
}
|
||||
b"ri" => {
|
||||
self.current.rendering_intent = Some(op.clone());
|
||||
}
|
||||
b"i" => {
|
||||
self.current.flatness = Some(op.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_inherited_drawing_ops(source: &DrawingState, dest: &DrawingState, extracted_state_ops: &[Op]) -> Vec<Op> {
|
||||
let mut ops = Vec::new();
|
||||
|
||||
// 1. Non-stroking color
|
||||
let has_non_stroking = extracted_state_ops.iter().any(|o| matches!(o.op.as_slice(), b"g" | b"rg" | b"k" | b"sc" | b"scn"));
|
||||
if !has_non_stroking {
|
||||
if let Some(ref color_ops) = source.non_stroking_color {
|
||||
ops.extend(color_ops.iter().cloned());
|
||||
} else if dest.non_stroking_color.is_some() {
|
||||
ops.push(Op::new("g", vec![pdfcraft_content::num(0.0)]));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Stroking color
|
||||
let has_stroking = extracted_state_ops.iter().any(|o| matches!(o.op.as_slice(), b"G" | b"RG" | b"K" | b"SC" | b"SCN"));
|
||||
if !has_stroking {
|
||||
if let Some(ref color_ops) = source.stroking_color {
|
||||
ops.extend(color_ops.iter().cloned());
|
||||
} else if dest.stroking_color.is_some() {
|
||||
ops.push(Op::new("G", vec![pdfcraft_content::num(0.0)]));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Line width
|
||||
if !extracted_state_ops.iter().any(|o| o.is("w")) {
|
||||
if let Some(ref op) = source.line_width {
|
||||
ops.push(op.clone());
|
||||
} else if dest.line_width.is_some() {
|
||||
ops.push(Op::new("w", vec![pdfcraft_content::num(1.0)]));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Line cap
|
||||
if !extracted_state_ops.iter().any(|o| o.is("J")) {
|
||||
if let Some(ref op) = source.line_cap {
|
||||
ops.push(op.clone());
|
||||
} else if dest.line_cap.is_some() {
|
||||
ops.push(Op::new("J", vec![pdfcraft_content::num(0.0)]));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Line join
|
||||
if !extracted_state_ops.iter().any(|o| o.is("j")) {
|
||||
if let Some(ref op) = source.line_join {
|
||||
ops.push(op.clone());
|
||||
} else if dest.line_join.is_some() {
|
||||
ops.push(Op::new("j", vec![pdfcraft_content::num(0.0)]));
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Miter limit
|
||||
if !extracted_state_ops.iter().any(|o| o.is("M")) {
|
||||
if let Some(ref op) = source.miter_limit {
|
||||
ops.push(op.clone());
|
||||
} else if dest.miter_limit.is_some() {
|
||||
ops.push(Op::new("M", vec![pdfcraft_content::num(10.0)]));
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Dash pattern
|
||||
if !extracted_state_ops.iter().any(|o| o.is("d")) {
|
||||
if let Some(ref op) = source.dash_pattern {
|
||||
ops.push(op.clone());
|
||||
} else if dest.dash_pattern.is_some() {
|
||||
ops.push(Op::new("d", vec![Object::Array(vec![]), pdfcraft_content::num(0.0)]));
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Rendering intent
|
||||
if !extracted_state_ops.iter().any(|o| o.is("ri"))
|
||||
&& let Some(ref op) = source.rendering_intent
|
||||
{
|
||||
ops.push(op.clone());
|
||||
}
|
||||
|
||||
// 9. Flatness
|
||||
if !extracted_state_ops.iter().any(|o| o.is("i"))
|
||||
&& let Some(ref op) = source.flatness
|
||||
{
|
||||
ops.push(op.clone());
|
||||
}
|
||||
|
||||
ops
|
||||
}
|
||||
|
||||
fn clip_path_to_ops(clip: &ClipPath) -> Vec<Op> {
|
||||
let mut ops = Vec::new();
|
||||
let mut current_pt = Point::ZERO;
|
||||
@@ -1138,6 +1319,7 @@ fn scan_leaking_ops(ops: &[Op], rem_start: usize, rem_end: usize) -> (Vec<Op>, b
|
||||
fn build_relocated_unit_ops(
|
||||
marked_open: Option<&Op>,
|
||||
source_gs_ops: &[(Op, Matrix)],
|
||||
inherited_drawing_ops: &[Op],
|
||||
extracted: ExtractedScopeState,
|
||||
dest_ctm: Matrix,
|
||||
draw_matrix: [f64; 6],
|
||||
@@ -1165,6 +1347,8 @@ fn build_relocated_unit_ops(
|
||||
unit_ops.push(gs_op.clone());
|
||||
}
|
||||
|
||||
unit_ops.extend(inherited_drawing_ops.iter().cloned());
|
||||
|
||||
let non_gs_ops = extracted.state_ops.into_iter().filter(|o| !o.is("gs"));
|
||||
unit_ops.extend(non_gs_ops);
|
||||
|
||||
@@ -1260,9 +1444,12 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
|
||||
|
||||
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
|
||||
let mut tracker = ExtendedGStateTracker::default();
|
||||
let mut drawing_tracker = DrawingStateTracker::default();
|
||||
let mut source_effective_state = EffectiveGState::default();
|
||||
let mut source_gs_ops = Vec::new();
|
||||
let mut source_drawing_state = DrawingState::default();
|
||||
let mut dest_effective_state = EffectiveGState::default();
|
||||
let mut dest_drawing_state = DrawingState::default();
|
||||
let mut dest_ctm = Matrix::IDENTITY;
|
||||
let mut scope_start_ctm = Matrix::IDENTITY;
|
||||
let mut outer_clips = Vec::new();
|
||||
@@ -1282,9 +1469,11 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
|
||||
if i == target {
|
||||
source_effective_state = tracker.current_state.clone();
|
||||
source_gs_ops = tracker.active_gs_ops.iter().map(|(_, op, ctm)| (op.clone(), *ctm)).collect();
|
||||
source_drawing_state = drawing_tracker.current.clone();
|
||||
}
|
||||
if i == scan_target {
|
||||
dest_effective_state = tracker.current_state.clone();
|
||||
dest_drawing_state = drawing_tracker.current.clone();
|
||||
dest_ctm = state.placement.matrix;
|
||||
dest_clips = state.placement.clips.clone();
|
||||
}
|
||||
@@ -1293,12 +1482,14 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
|
||||
}
|
||||
state.apply(op)?;
|
||||
tracker.step(op, &ext_gstates, doc, state.placement.matrix)?;
|
||||
drawing_tracker.step(op)?;
|
||||
if i == rem_end.saturating_sub(1) {
|
||||
ctm_after_removal = state.placement.matrix;
|
||||
}
|
||||
}
|
||||
if scan_target >= ops.len() {
|
||||
dest_effective_state = tracker.current_state.clone();
|
||||
dest_drawing_state = drawing_tracker.current.clone();
|
||||
dest_ctm = state.placement.matrix;
|
||||
dest_clips = state.placement.clips.clone();
|
||||
}
|
||||
@@ -1370,7 +1561,9 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
|
||||
|
||||
let marked_open = if marked_unit && !has_other_paint { ops.get(target_unit.start) } else { None };
|
||||
let has_closing_emc = marked_open.is_some() && target_unit.end.checked_sub(1).and_then(|i| ops.get(i)).is_some_and(|o| o.is("EMC"));
|
||||
let unit_ops = build_relocated_unit_ops(marked_open, &source_gs_ops, extracted, dest_ctm, img.draw_matrix, has_closing_emc)?;
|
||||
let inherited_drawing_ops = compute_inherited_drawing_ops(&source_drawing_state, &dest_drawing_state, &extracted.state_ops);
|
||||
let unit_ops =
|
||||
build_relocated_unit_ops(marked_open, &source_gs_ops, &inherited_drawing_ops, extracted, dest_ctm, img.draw_matrix, has_closing_emc)?;
|
||||
|
||||
let mut serialized = Vec::new();
|
||||
serialized.push(b'\n');
|
||||
|
||||
@@ -3540,6 +3540,57 @@ fn image_page_with(content: &str) -> Document {
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
fn stencil_page_with(content: &str) -> Document {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>"
|
||||
.into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >>\nstream\n0000>\nendstream".into(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
fn form_page_with(content: &str) -> Document {
|
||||
let form_content = "0 0 100 100 re f";
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 6 0 R >> >> >>"
|
||||
.into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{form_content}\nendstream", form_content.len()),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
/// A path segment operator before any `m` (or `re`) has no subpath to extend: the image scan used
|
||||
/// to call `BezPath`'s `line_to`/`curve_to`/`close_path` on an empty path, which panics by contract,
|
||||
/// taking `object_list`, `page_images` and the Office export's image walk down with it (#3581).
|
||||
@@ -8878,6 +8929,78 @@ fn arrange_preserves_leaked_color_and_text_state() {
|
||||
let idx = (((page.height - 5) * page.width + 5) * 4) as usize;
|
||||
assert_eq!(page.rgba[idx..idx + 3], [255, 0, 0], "point (5, 5 from bottom) inside 10x10 rect fills red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_preserves_inherited_drawing_state_for_stencil_and_form() {
|
||||
// 1. Stencil mask test:
|
||||
// Page has: q 1 0 0 rg q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET
|
||||
// Non-overlapping stencil and text.
|
||||
let content = "q 1 0 0 rg q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET";
|
||||
let mut doc = stencil_page_with(content);
|
||||
|
||||
// Render before move:
|
||||
let pdf_before = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let mut renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(pdf_before), pdfcraft_render::RenderConfig::default());
|
||||
let page_before = renderer_before.render(pdfcraft_render::RenderRequest {
|
||||
page: 0,
|
||||
kind: pdfcraft_render::RequestKind::Pixels,
|
||||
scale: 1.0,
|
||||
..Default::default()
|
||||
});
|
||||
let stencil_pixel_idx = (((page_before.height - 75) * page_before.width + 75) * 4) as usize;
|
||||
assert_eq!(page_before.rgba[stencil_pixel_idx..stencil_pixel_idx + 3], [255, 0, 0], "stencil starts red");
|
||||
|
||||
// Bring to front
|
||||
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
|
||||
|
||||
// Save and reopen
|
||||
let doc = reopen(&doc);
|
||||
|
||||
// Verify content stream has inherited color
|
||||
let bytes = page_content_bytes(&doc, 0);
|
||||
let s = String::from_utf8_lossy(&bytes);
|
||||
assert!(s.contains("1 0 0 rg"), "inherited 1 0 0 rg must be replayed in moved unit: {s}");
|
||||
|
||||
// Render after reopen and verify stencil remains red:
|
||||
let pdf_after = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let mut renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(pdf_after), pdfcraft_render::RenderConfig::default());
|
||||
let page_after = renderer_after.render(pdfcraft_render::RenderRequest {
|
||||
page: 0,
|
||||
kind: pdfcraft_render::RequestKind::Pixels,
|
||||
scale: 1.0,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(page_after.rgba[stencil_pixel_idx..stencil_pixel_idx + 3], [255, 0, 0], "stencil stays red after BringToFront and reopen");
|
||||
|
||||
// 2. Form XObject test:
|
||||
// Page has: q 0 1 0 rg q 1 0 0 1 50 50 cm /Fm0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET
|
||||
let form_content = "q 0 1 0 rg q 1 0 0 1 50 50 cm /Fm0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET";
|
||||
let mut form_doc = form_page_with(form_content);
|
||||
|
||||
let form_pdf_before = write_incremental(&form_doc, &SaveOptions::default()).unwrap();
|
||||
let mut form_renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(form_pdf_before), pdfcraft_render::RenderConfig::default());
|
||||
let form_page_before = form_renderer_before.render(pdfcraft_render::RenderRequest {
|
||||
page: 0,
|
||||
kind: pdfcraft_render::RequestKind::Pixels,
|
||||
scale: 1.0,
|
||||
..Default::default()
|
||||
});
|
||||
let form_pixel_idx = (((form_page_before.height - 75) * form_page_before.width + 75) * 4) as usize;
|
||||
assert_eq!(form_page_before.rgba[form_pixel_idx..form_pixel_idx + 3], [0, 255, 0], "form starts green");
|
||||
|
||||
images::arrange_image(&mut form_doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
|
||||
let form_doc = reopen(&form_doc);
|
||||
|
||||
let form_pdf_after = write_incremental(&form_doc, &SaveOptions::default()).unwrap();
|
||||
let mut form_renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(form_pdf_after), pdfcraft_render::RenderConfig::default());
|
||||
let form_page_after = form_renderer_after.render(pdfcraft_render::RenderRequest {
|
||||
page: 0,
|
||||
kind: pdfcraft_render::RequestKind::Pixels,
|
||||
scale: 1.0,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(form_page_after.rgba[form_pixel_idx..form_pixel_idx + 3], [0, 255, 0], "form stays green after BringToFront and reopen");
|
||||
}
|
||||
/// Existing-row alignment is explicitly refused without mutating the paragraph.
|
||||
#[test]
|
||||
fn one_row_of_a_paragraph_takes_its_own_alignment_is_refused_without_changing_the_document() {
|
||||
|
||||
@@ -373,7 +373,10 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
|
||||
ai.rect = bbox(to_display, *to);
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Transform(t) => {
|
||||
ImageEdit::Transform(_) | ImageEdit::Flip { .. } => {
|
||||
let pdfcraft_edit::ImageChange::Transform(t) = c else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected transform".into())));
|
||||
};
|
||||
let mul = |m1: [f64; 6], m2: [f64; 6]| {
|
||||
[
|
||||
m1[0] * m2[0] + m1[1] * m2[2],
|
||||
@@ -384,7 +387,7 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
|
||||
m1[4] * m2[1] + m1[5] * m2[3] + m2[5],
|
||||
]
|
||||
};
|
||||
let t_display = mul(mul(view, *t), to_display);
|
||||
let t_display = mul(mul(view, t), to_display);
|
||||
let is_parallel = t_display[1].abs() < 1e-5 && t_display[2].abs() < 1e-5;
|
||||
let is_perpendicular = t_display[0].abs() < 1e-5 && t_display[3].abs() < 1e-5;
|
||||
|
||||
@@ -434,14 +437,6 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
|
||||
}
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Flip { horizontal: true } => {
|
||||
ai.flip_h = !ai.flip_h;
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Flip { horizontal: false } => {
|
||||
ai.flip_v = !ai.flip_v;
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Replace { .. } => {
|
||||
let pdfcraft_edit::ImageChange::Replace(new_image) = c else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into())));
|
||||
|
||||
+115
-22
@@ -9495,37 +9495,130 @@ fn transform_on_added_image_folds_quarter_turns_and_rejects_non_axis_aligned() {
|
||||
|
||||
#[test]
|
||||
fn test_added_image_transform_reflections_with_rotation() {
|
||||
let make_png = |rgb: [u8; 3]| {
|
||||
let make_asymmetric_png = || {
|
||||
let mut px = Vec::with_capacity(20 * 20 * 3);
|
||||
for y in 0..20 {
|
||||
for x in 0..20 {
|
||||
let color = match (x < 10, y < 10) {
|
||||
(true, true) => [255, 0, 0], // TL: Red
|
||||
(false, true) => [0, 255, 0], // TR: Green
|
||||
(true, false) => [0, 0, 255], // BL: Blue
|
||||
(false, false) => [255, 255, 0], // BR: Yellow
|
||||
};
|
||||
px.extend_from_slice(&color);
|
||||
}
|
||||
}
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
let mut enc = png::Encoder::new(&mut png, 20, 20);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
|
||||
enc.write_header().unwrap().write_image_data(&data).unwrap();
|
||||
enc.write_header().unwrap().write_image_data(&px).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
let png = make_png([255, 0, 0]);
|
||||
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 150.0]), name: "img.png".into(), bytes: png }).unwrap();
|
||||
let colour_at = |s: &Session, id: DocId, x: u32, y_from_top: u32| -> [u8; 3] {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
|
||||
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
let i = ((y_from_top * out.width + x) * 4) as usize;
|
||||
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]
|
||||
};
|
||||
|
||||
// Rotate by 90° CW (quarter turns CCW: rotation = 3):
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 3);
|
||||
assert!(!ai.flip_h);
|
||||
assert!(!ai.flip_v);
|
||||
let is_red = |c: [u8; 3]| c[0] > 200 && c[1] < 50 && c[2] < 50;
|
||||
let is_green = |c: [u8; 3]| c[0] < 50 && c[1] > 200 && c[2] < 50;
|
||||
let is_blue = |c: [u8; 3]| c[0] < 50 && c[1] < 50 && c[2] > 200;
|
||||
let is_yellow = |c: [u8; 3]| c[0] > 200 && c[1] > 200 && c[2] < 50;
|
||||
|
||||
// Apply a horizontal flip in display space (parallel transform with m0 = -1):
|
||||
// For rotation = 3 (odd rotation), display horizontal flip corresponds to local vertical flip (flip_v):
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 300.0, 0.0]) }).unwrap();
|
||||
let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!() };
|
||||
assert_eq!(ai2.rotation, 3);
|
||||
assert!(!ai2.flip_h, "flip_h should NOT be toggled for horizontal flip when rotation is 90°");
|
||||
assert!(ai2.flip_v, "flip_v should be toggled for horizontal flip when rotation is 90°");
|
||||
let check_quadrants =
|
||||
|s: &Session, id: DocId, tl: fn([u8; 3]) -> bool, tr: fn([u8; 3]) -> bool, bl: fn([u8; 3]) -> bool, br: fn([u8; 3]) -> bool, label: &str| {
|
||||
let c_tl = colour_at(s, id, 75, 125);
|
||||
let c_tr = colour_at(s, id, 125, 125);
|
||||
let c_bl = colour_at(s, id, 75, 175);
|
||||
let c_br = colour_at(s, id, 125, 175);
|
||||
assert!(tl(c_tl), "{label} TL mismatch: got {:?}", c_tl);
|
||||
assert!(tr(c_tr), "{label} TR mismatch: got {:?}", c_tr);
|
||||
assert!(bl(c_bl), "{label} BL mismatch: got {:?}", c_bl);
|
||||
assert!(br(c_br), "{label} BR mismatch: got {:?}", c_br);
|
||||
};
|
||||
|
||||
// --- CASE 1: 90° clockwise rotation (ImageEdit::Rotate(1), ai.rotation = 3) ---
|
||||
// Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow.
|
||||
// After 90° CW: TL=Blue, TR=Red, BL=Yellow, BR=Green.
|
||||
{
|
||||
// 1a: Flip Horizontal after 90° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
check_quadrants(&s, id, is_red, is_green, is_blue, is_yellow, "initial");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
check_quadrants(&s, id, is_blue, is_red, is_yellow, is_green, "90° CW");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 3);
|
||||
assert!(!ai.flip_h, "at 90° rotation, horizontal flip toggles flip_v, not flip_h");
|
||||
assert!(ai.flip_v, "at 90° rotation, horizontal flip toggles flip_v");
|
||||
// Flipping horizontally on screen swaps left & right columns: TL=Red, TR=Blue, BL=Green, BR=Yellow
|
||||
check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "90° CW + flip horizontal");
|
||||
}
|
||||
|
||||
{
|
||||
// 1b: Flip Vertical after 90° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 3);
|
||||
assert!(ai.flip_h, "at 90° rotation, vertical flip toggles flip_h");
|
||||
assert!(!ai.flip_v, "at 90° rotation, vertical flip toggles flip_h, not flip_v");
|
||||
// Flipping vertically on screen swaps top & bottom rows: TL=Yellow, TR=Green, BL=Blue, BR=Red
|
||||
check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "90° CW + flip vertical");
|
||||
}
|
||||
|
||||
// --- CASE 2: 270° clockwise rotation (ImageEdit::Rotate(3), ai.rotation = 1) ---
|
||||
// Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow.
|
||||
// After 270° CW: TL=Green, TR=Yellow, BL=Red, BR=Blue.
|
||||
{
|
||||
// 2a: Flip Horizontal after 270° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap();
|
||||
check_quadrants(&s, id, is_green, is_yellow, is_red, is_blue, "270° CW");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 1);
|
||||
assert!(!ai.flip_h, "at 270° rotation, horizontal flip toggles flip_v, not flip_h");
|
||||
assert!(ai.flip_v, "at 270° rotation, horizontal flip toggles flip_v");
|
||||
// Flipping horizontally on screen swaps left & right columns: TL=Yellow, TR=Green, BL=Blue, BR=Red
|
||||
check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "270° CW + flip horizontal");
|
||||
}
|
||||
|
||||
{
|
||||
// 2b: Flip Vertical after 270° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap();
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 1);
|
||||
assert!(ai.flip_h, "at 270° rotation, vertical flip toggles flip_h");
|
||||
assert!(!ai.flip_v, "at 270° rotation, vertical flip toggles flip_h, not flip_v");
|
||||
// Flipping vertically on screen swaps top & bottom rows: TL=Red, TR=Blue, BL=Green, BR=Yellow
|
||||
check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "270° CW + flip vertical");
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb
|
||||
|
||||
@@ -47,6 +47,7 @@ mod menus;
|
||||
pub mod marks {
|
||||
pub use crate::marks_ui::{MarksDraft, PageRange, Subset};
|
||||
}
|
||||
pub use content_ui::TextDraft;
|
||||
pub use create_ui::Clip;
|
||||
pub use link_ui::LinkDraft;
|
||||
pub use optimize_ui::{OptimizeDraft, OptimizeTab};
|
||||
|
||||
@@ -281,6 +281,12 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
Some(paragraphs),
|
||||
);
|
||||
let mut changed = out.changed;
|
||||
if out.toggle_superscript && crate::edit_text_ui::toggle_superscript(&mut ed) {
|
||||
changed = true;
|
||||
}
|
||||
if out.toggle_subscript && crate::edit_text_ui::toggle_subscript(&mut ed) {
|
||||
changed = true;
|
||||
}
|
||||
match out.font {
|
||||
Some(FontChoice::KeepSource) => {
|
||||
ed.keep_source_font();
|
||||
@@ -315,7 +321,39 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
let mut style = draft.style.clone();
|
||||
let paragraphs = crate::format_text_ui::Paragraphs { text: &draft.text, selection: draft.selection.clone(), tidy: true };
|
||||
added_text_format(ui, t, &mut style, Some(paragraphs));
|
||||
let out = added_text_format(ui, t, &mut style, Some(paragraphs));
|
||||
if out.toggle_superscript || out.toggle_subscript {
|
||||
let (s_pos, e_pos) =
|
||||
if let Some(r) = &draft.selection { (r.start.min(draft.text.len()), r.end.min(draft.text.len())) } else { (0, draft.text.len()) };
|
||||
let (start, end) = crate::edit_text_ui::snap_char_boundaries(&draft.text, s_pos, e_pos);
|
||||
if start < end {
|
||||
let target = &draft.text[start..end];
|
||||
let converted: String = if out.toggle_superscript {
|
||||
let all_sup = target.chars().all(|c| crate::edit_text_ui::char_from_superscript(c) != c);
|
||||
if all_sup {
|
||||
target.chars().map(crate::edit_text_ui::char_from_superscript).collect()
|
||||
} else {
|
||||
target.chars().map(crate::edit_text_ui::char_to_superscript).collect()
|
||||
}
|
||||
} else {
|
||||
let all_sub = target.chars().all(|c| crate::edit_text_ui::char_from_subscript(c) != c);
|
||||
if all_sub {
|
||||
target.chars().map(crate::edit_text_ui::char_from_subscript).collect()
|
||||
} else {
|
||||
target.chars().map(crate::edit_text_ui::char_to_subscript).collect()
|
||||
}
|
||||
};
|
||||
if converted != target {
|
||||
let mut new_draft = draft.clone();
|
||||
let conv_len = converted.len();
|
||||
new_draft.text.replace_range(start..end, &converted);
|
||||
if new_draft.selection.is_some() {
|
||||
new_draft.selection = Some(start..start + conv_len);
|
||||
}
|
||||
app.views[i].content.draft = Some(new_draft);
|
||||
}
|
||||
}
|
||||
}
|
||||
if style != draft.style {
|
||||
app.restyle_draft(i, style);
|
||||
}
|
||||
@@ -373,12 +411,18 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
|
||||
/// Format text for added text: the standard families, size, colour, style and alignment
|
||||
/// (of the lines `paragraphs` selects, while the text is being typed).
|
||||
fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText, paragraphs: Option<crate::format_text_ui::Paragraphs>) {
|
||||
fn added_text_format(
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
style: &mut pdfcraft_engine::AddedText,
|
||||
paragraphs: Option<crate::format_text_ui::Paragraphs>,
|
||||
) -> crate::format_text_ui::Outcome {
|
||||
let shown = style.family.label().to_string();
|
||||
let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown, mixed_size: false }, paragraphs);
|
||||
if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font {
|
||||
style.family = family;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
@@ -418,6 +462,7 @@ impl PdfCraftApp {
|
||||
/// Apply the open paragraph's formatting as one change; the paragraph stays open.
|
||||
pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) {
|
||||
let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() };
|
||||
let sel = ed.selection.clone();
|
||||
if self.apply_edit(edit) {
|
||||
ed.applied();
|
||||
if let Some(doc) = self.session.get(self.views[index].id)
|
||||
@@ -428,6 +473,9 @@ impl PdfCraftApp {
|
||||
ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, ed.page);
|
||||
}
|
||||
}
|
||||
if sel.is_some() {
|
||||
ed.selection = sel;
|
||||
}
|
||||
}
|
||||
self.views[index].line_editor = Some(ed);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! horizontal scale and character spacing. One panel serves a paragraph being edited (with the
|
||||
//! fonts already on its page and the spacing controls) and added text (the standard families,
|
||||
//! size, colour, style, alignment and character spacing). What PdfCraft can't apply yet (lists,
|
||||
//! superscript and subscript, paragraph spacing, or line spacing and scale for added text) is shown
|
||||
//! paragraph spacing, or line spacing and scale for added text) is shown
|
||||
//! disabled and says so.
|
||||
//!
|
||||
//! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat.
|
||||
@@ -87,6 +87,8 @@ impl Paragraphs<'_> {
|
||||
pub(crate) struct Outcome {
|
||||
pub changed: bool,
|
||||
pub font: Option<FontChoice>,
|
||||
pub toggle_superscript: bool,
|
||||
pub toggle_subscript: bool,
|
||||
}
|
||||
|
||||
const SIZES: [f64; 16] = [4.0, 6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0];
|
||||
@@ -298,8 +300,35 @@ pub(crate) fn panel(
|
||||
}
|
||||
None => planned(ui, "underline", tl!("Underline")),
|
||||
}
|
||||
planned(ui, "superscript", tl!("Superscript"));
|
||||
planned(ui, "subscript", tl!("Subscript"));
|
||||
let (is_super, is_sub) = paragraphs
|
||||
.as_ref()
|
||||
.map(|p| {
|
||||
let (s_pos, e_pos) =
|
||||
if let Some(r) = &p.selection { (r.start.min(p.text.len()), r.end.min(p.text.len())) } else { (0, p.text.len()) };
|
||||
let (start, end) = crate::edit_text_ui::snap_char_boundaries(p.text, s_pos, e_pos);
|
||||
if start >= end {
|
||||
return (false, false);
|
||||
}
|
||||
let target = &p.text[start..end];
|
||||
let all_sup = !target.is_empty() && target.chars().all(|c| crate::edit_text_ui::char_from_superscript(c) != c);
|
||||
let all_sub = !target.is_empty() && target.chars().all(|c| crate::edit_text_ui::char_from_subscript(c) != c);
|
||||
(all_sup, all_sub)
|
||||
})
|
||||
.unwrap_or((false, false));
|
||||
|
||||
if paragraphs.is_some() {
|
||||
if icons::button(ui, "superscript", 30.0, is_super, tl!("Superscript")).clicked() {
|
||||
out.toggle_superscript = true;
|
||||
out.changed = true;
|
||||
}
|
||||
if icons::button(ui, "subscript", 30.0, is_sub, tl!("Subscript")).clicked() {
|
||||
out.toggle_subscript = true;
|
||||
out.changed = true;
|
||||
}
|
||||
} else {
|
||||
planned(ui, "superscript", tl!("Superscript"));
|
||||
planned(ui, "subscript", tl!("Subscript"));
|
||||
}
|
||||
});
|
||||
|
||||
// Line and paragraph spacing; horizontal scale and character spacing: two columns, each an
|
||||
@@ -352,7 +381,7 @@ pub(crate) fn panel(
|
||||
number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing")));
|
||||
});
|
||||
|
||||
out.changed = size_committed || (look.clone(), extras.as_deref().copied()) != before;
|
||||
out.changed = out.changed || size_committed || (look.clone(), extras.as_deref().copied()) != before;
|
||||
ui.add_space(6.0);
|
||||
ui.separator();
|
||||
out
|
||||
|
||||
@@ -26,7 +26,7 @@ pub(crate) use image::{
|
||||
ImageCx, Matrix, body_hit as picture_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, user_to_screen,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use image::{rotate_knob, turn_degrees};
|
||||
use image::{rotate_knob, turn_degrees, turn_degrees_delta};
|
||||
#[cfg(test)]
|
||||
use layout::{DARK_EDITOR_FILL, contrast};
|
||||
pub(crate) use layout::{caret_color_on, editor_fill, editor_font, install_source_fonts};
|
||||
@@ -370,7 +370,7 @@ pub fn char_from_subscript(c: char) -> char {
|
||||
}
|
||||
}
|
||||
|
||||
fn snap_char_boundaries(s: &str, start: usize, end: usize) -> (usize, usize) {
|
||||
pub(crate) fn snap_char_boundaries(s: &str, start: usize, end: usize) -> (usize, usize) {
|
||||
let mut s_idx = start.min(s.len());
|
||||
while s_idx > 0 && !s.is_char_boundary(s_idx) {
|
||||
s_idx -= 1;
|
||||
@@ -448,7 +448,7 @@ pub struct LineEditor {
|
||||
/// Current screen-space size. Recomputed from the current page transform before painting.
|
||||
size: f32,
|
||||
/// Take the keyboard focus on the next frame.
|
||||
pub(crate) focus: bool,
|
||||
pub focus: bool,
|
||||
/// Whether the text box had the keyboard at the end of the editor's last frame
|
||||
/// ([`lost_focus_once`]).
|
||||
had_focus: bool,
|
||||
@@ -1310,23 +1310,26 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
|
||||
}
|
||||
}
|
||||
look = Some((Arc::clone(&output.galley), output.galley_pos));
|
||||
if let Some(cursor) = output.cursor_range {
|
||||
let selected = cursor.as_sorted_char_range();
|
||||
let byte = |index| ed.text.char_indices().nth(index).map_or(ed.text.len(), |(at, _)| at);
|
||||
ed.selection = (!selected.is_empty()).then(|| byte(selected.start.into())..byte(selected.end.into()));
|
||||
}
|
||||
let r = output.response;
|
||||
if r.changed() {
|
||||
ed.follow_typed_rows(ctx, &before);
|
||||
// The next pass measures the updated draft with the Apply/Save rewrite.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if ed.focus {
|
||||
r.request_focus();
|
||||
if let Some(range) = &ed.selection {
|
||||
reselect(ui.ctx(), input, &ed.text, range);
|
||||
}
|
||||
ed.focus = false;
|
||||
} else if r.has_focus() {
|
||||
if let Some(cursor) = output.cursor_range {
|
||||
let selected = cursor.as_sorted_char_range();
|
||||
let byte = |index| ed.text.char_indices().nth(index).map_or(ed.text.len(), |(at, _)| at);
|
||||
ed.selection = (!selected.is_empty()).then(|| byte(selected.start.into())..byte(selected.end.into()));
|
||||
} else {
|
||||
ed.selection = None;
|
||||
}
|
||||
}
|
||||
if r.changed() {
|
||||
ed.follow_typed_rows(ctx, &before);
|
||||
// The next pass measures the updated draft with the Apply/Save rewrite.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let lost = lost_focus_once(&r, &mut ed.had_focus);
|
||||
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
@@ -1519,6 +1522,22 @@ C", &picked
|
||||
assert_eq!(at(f32::NAN, 5.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rotate_knob_measures_clockwise_delta_from_drag_start() {
|
||||
let c = Pos2::new(100.0, 100.0);
|
||||
// Start dragging at 45° clockwise from top:
|
||||
let r = 50.0_f32;
|
||||
let start = c + egui::vec2(r * 45.0_f32.to_radians().sin(), -r * 45.0_f32.to_radians().cos());
|
||||
// At start position, delta is 0:
|
||||
assert!(turn_degrees_delta(c, start, start, false).abs() < 1e-3);
|
||||
// Move by +15° (to 60°):
|
||||
let p_60 = c + egui::vec2(r * 60.0_f32.to_radians().sin(), -r * 60.0_f32.to_radians().cos());
|
||||
assert!((turn_degrees_delta(c, start, p_60, false) - 15.0).abs() < 1e-2);
|
||||
// Move by -10° (to 35°):
|
||||
let p_35 = c + egui::vec2(r * 35.0_f32.to_radians().sin(), -r * 35.0_f32.to_radians().cos());
|
||||
assert!((turn_degrees_delta(c, start, p_35, false) + 10.0).abs() < 1e-2);
|
||||
}
|
||||
|
||||
/// An app with one text document open, and an editor on its first paragraph.
|
||||
fn preview_app() -> (crate::PdfCraftApp, LineEditor) {
|
||||
let mut app = crate::PdfCraftApp::new();
|
||||
|
||||
@@ -520,18 +520,33 @@ pub(super) fn rotate_knob(b: Rect, view: Rect) -> (Pos2, Pos2) {
|
||||
if above.y - 6.0 >= view.top() { (above, b.center_top()) } else { (b.center_bottom() + egui::vec2(0.0, 22.0), b.center_bottom()) }
|
||||
}
|
||||
|
||||
/// Degrees clockwise (-180..=180) that turn "straight up" from `centre` onto the pointer; `snap`
|
||||
/// Degrees clockwise (-180..=180) that turn from `start` onto `pointer` around `centre`; `snap`
|
||||
/// rounds to steps of 15 degrees (Shift). A pointer on the centre turns nothing.
|
||||
pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 {
|
||||
let v = pointer - centre;
|
||||
if !(v.x.is_finite() && v.y.is_finite()) || v.length() < 4.0 {
|
||||
pub(super) fn turn_degrees_delta(centre: Pos2, start: Pos2, pointer: Pos2, snap: bool) -> f32 {
|
||||
let v_start = start - centre;
|
||||
let v_curr = pointer - centre;
|
||||
if !(v_curr.x.is_finite() && v_curr.y.is_finite())
|
||||
|| v_curr.length() < 4.0
|
||||
|| !(v_start.x.is_finite() && v_start.y.is_finite())
|
||||
|| v_start.length() < 4.0
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
// Screen y points down: up is (0, -1); clockwise is atan2(x, -y).
|
||||
let d = v.x.atan2(-v.y).to_degrees();
|
||||
let theta_start = v_start.x.atan2(-v_start.y).to_degrees();
|
||||
let theta_curr = v_curr.x.atan2(-v_curr.y).to_degrees();
|
||||
let mut d = theta_curr - theta_start;
|
||||
d = ((d + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
if snap { (d / 15.0).round() * 15.0 } else { d }
|
||||
}
|
||||
|
||||
/// Degrees clockwise (-180..=180) that turn "straight up" from `centre` onto the pointer; `snap`
|
||||
/// rounds to steps of 15 degrees (Shift). A pointer on the centre turns nothing.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 {
|
||||
turn_degrees_delta(centre, centre - egui::vec2(0.0, 10.0), pointer, snap)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn image_menu_id() -> egui::Id {
|
||||
egui::Id::unique("image-menu")
|
||||
@@ -745,17 +760,19 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
|
||||
|
||||
if view.image_selection.as_ref().is_some_and(|s| s.turning) {
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift));
|
||||
let centre = b.center();
|
||||
let centre = obb.screen_center;
|
||||
let rot_knob = if obb.is_rotated { obb.s_rot } else { knob };
|
||||
let drag_start = view.image_selection.as_ref().and_then(|s| s.drag).map_or(rot_knob, |d| d.start);
|
||||
let degrees = hover_pos.map_or(0.0, |p| turn_degrees_delta(centre, drag_start, p, shift));
|
||||
let (sin, cos) = degrees.to_radians().sin_cos();
|
||||
let turn = |c: Pos2| {
|
||||
let v = c - centre;
|
||||
centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos)
|
||||
};
|
||||
let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec();
|
||||
let outline = obb.corners().map(turn).to_vec();
|
||||
painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT)));
|
||||
let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT);
|
||||
let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0));
|
||||
let label = Rect::from_center_size(turn(rot_knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0));
|
||||
painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
|
||||
painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT);
|
||||
view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing);
|
||||
|
||||
@@ -4306,6 +4306,83 @@ fn dragging_the_rotate_knob_turns_a_page_image() {
|
||||
assert!((turned[2] - turned[0] - hh).abs() < 1.5 && (turned[3] - turned[1] - w).abs() < 1.5, "{before:?} -> {turned:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_rotate_knob_on_already_rotated_image_applies_delta_only() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("picture.png", None, png.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let id = h.state().views[0].id;
|
||||
|
||||
// Center picture
|
||||
let full = h.state().session.get(id).unwrap().page_images(0)[0].rect;
|
||||
let (qw, qh) = ((full[2] - full[0]) / 4.0, (full[3] - full[1]) / 4.0);
|
||||
let (mx, my) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0);
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
|
||||
page: 0,
|
||||
index: 0,
|
||||
change: pdfcraft_engine::ImageEdit::Move([mx - qw, my - qh, mx + qw, my + qh]),
|
||||
});
|
||||
h.run_steps(4);
|
||||
|
||||
// Rotate the image by 45° clockwise initially
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::RotateBy(45.0) });
|
||||
h.run_steps(4);
|
||||
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let c = r.center();
|
||||
|
||||
// Select the picture
|
||||
h.hover_at(c);
|
||||
h.run_steps(1);
|
||||
h.drag_at(c);
|
||||
h.run_steps(1);
|
||||
h.drop_at(c);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].image_selection.is_some(), "selected");
|
||||
|
||||
let initial_img = &h.state().session.get(id).unwrap().page_images(0)[0];
|
||||
let initial_matrix = initial_img.matrix;
|
||||
let xf = h.state().views[0].page_xform(0).expect("page 0 xform");
|
||||
let doc_info = &h.state().session.get(id).unwrap().info;
|
||||
let obb = pdfcraft_ui_egui::canvas::ImageObb::from_matrix(initial_matrix, &xf, doc_info, 0);
|
||||
let knob = obb.s_rot;
|
||||
let center = obb.screen_center;
|
||||
let radius = (knob - center).length();
|
||||
|
||||
// Hover at the rotated knob position:
|
||||
h.hover_at(knob);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab, "knob offers Grab cursor on 45° rotated image");
|
||||
|
||||
// Rotate clockwise by a small delta of 15° (from 45° to 60°):
|
||||
let to_angle = (45.0 + 15.0_f32).to_radians();
|
||||
let to = center + egui::vec2(radius * to_angle.sin(), -radius * to_angle.cos());
|
||||
|
||||
h.drag_at(knob);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(knob + (to - knob) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing);
|
||||
h.drop_at(to);
|
||||
h.run_steps(4);
|
||||
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
let final_img = &doc.page_images(0)[0];
|
||||
let angle_deg = (-final_img.matrix[1]).atan2(final_img.matrix[0]).to_degrees();
|
||||
let angle_deg = (angle_deg % 360.0 + 360.0) % 360.0;
|
||||
assert!((angle_deg - 60.0).abs() < 2.0, "expected ~60° total rotation, got {angle_deg}°");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_existing_form_artwork_on_the_page() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
|
||||
@@ -4686,6 +4763,70 @@ fn the_format_panel_restyles_the_paragraph_being_edited() {
|
||||
assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_format_panel_toggles_superscript_and_subscript_on_selection() {
|
||||
let mut h = harness(1, |_| {});
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(1);
|
||||
h.drop_at(at);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[0].line_editor.is_some());
|
||||
|
||||
// In line editor, select "1" in "Page 1":
|
||||
let ed = h.state_mut().views[0].line_editor.as_mut().unwrap();
|
||||
ed.selection = Some(5..6);
|
||||
ed.focus = true;
|
||||
h.run_steps(1);
|
||||
|
||||
// Click Superscript: "1" becomes "¹"
|
||||
h.get_by_label("FORMAT TEXT");
|
||||
let btn = h.get_by_label("Superscript");
|
||||
btn.click();
|
||||
h.run_steps(4);
|
||||
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
assert_eq!(doc.can_undo(), Some("Edit text"));
|
||||
assert_eq!(doc.text_blocks(0)[0].text, "Page ¹");
|
||||
|
||||
// Click Superscript again: "¹" toggles back to "1"
|
||||
h.get_by_label("Superscript").click();
|
||||
h.run_steps(4);
|
||||
let s2 = h.state();
|
||||
let doc2 = s2.session.get(s2.views[0].id).unwrap();
|
||||
assert_eq!(doc2.text_blocks(0)[0].text, "Page 1");
|
||||
|
||||
// Subscript toggle in added text:
|
||||
h.state_mut().views[0].line_editor = None;
|
||||
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::AddText;
|
||||
let draft = pdfcraft_ui_egui::TextDraft {
|
||||
page: 0,
|
||||
index: None,
|
||||
rect: [20.0, 20.0, 120.0, 40.0],
|
||||
text: "H2O".into(),
|
||||
style: Default::default(),
|
||||
focus: false,
|
||||
selection: Some(1..2),
|
||||
typing_from: None,
|
||||
};
|
||||
h.state_mut().views[0].content.draft = Some(draft);
|
||||
h.run_steps(2);
|
||||
|
||||
h.get_by_label("Subscript").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("H₂O"));
|
||||
|
||||
h.get_by_label("Subscript").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("H2O"));
|
||||
}
|
||||
|
||||
/// Horizontal scale and Character spacing take any value in their ranges, not only the listed
|
||||
/// ones: values between the listed ones and beyond them, typed in Format text, reach the saved
|
||||
/// file as the engine writes them.
|
||||
|
||||
@@ -1968,7 +1968,10 @@ milestone = "M7"
|
||||
status = "partial"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/src/tools/edit_text_ui.rs::superscript_and_subscript_conversion"]
|
||||
evidence = [
|
||||
"crates/ui-egui/src/tools/edit_text_ui.rs::superscript_and_subscript_conversion",
|
||||
"crates/ui-egui/tests/it/editing.rs::the_format_panel_toggles_superscript_and_subscript_on_selection",
|
||||
]
|
||||
notes = "Toggled in the Format text panel for the selection. Uses Unicode superscript and subscript forms (2 ↔ ²) rather than text rise (Ts) and font scaling, so the font must provide the corresponding glyphs and extracted text contains the Unicode characters."
|
||||
|
||||
[[feature]]
|
||||
|
||||
Reference in New Issue
Block a user