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:
Combiler01
2026-10-31 23:18:58 +05:30
parent 9b169fae50
commit fdc0e6404a
11 changed files with 724 additions and 62 deletions
+194 -1
View File
@@ -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');
+123
View File
@@ -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() {
+5 -10
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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};
+50 -2
View File
@@ -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);
}
+33 -4
View File
@@ -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
+32 -13
View File
@@ -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();
+26 -9
View File
@@ -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);
+141
View File
@@ -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.
+4 -1
View File
@@ -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]]