mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Integration: track t09
# Conflicts: # crates/engine/src/tests.rs # crates/ui-egui/src/i18n.rs # crates/ui-egui/src/i18n/pt.rs # docs/file-format-parity.md # docs/target-app-parity.md # docs/ui-parity.md
This commit is contained in:
@@ -330,6 +330,21 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
|
||||
}
|
||||
// Move whole lines (cells with formulas adjusted for relative references).
|
||||
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
|
||||
// Notes and hyperlinks hang off a cell, not a row, so they travel with the row they
|
||||
// describe — the way cell pictures and row heights do. Collect them before inserting,
|
||||
// so a swap can't resurrect one from a destination that is also a source.
|
||||
let side: Vec<(CellRef, Comment)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
|
||||
.filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone())))
|
||||
.collect();
|
||||
let links: Vec<(CellRef, Hyperlink)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
|
||||
.filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone())))
|
||||
.collect();
|
||||
let mut moved: Vec<(CellRef, Option<Cell>)> = Vec::with_capacity(body.count() as usize);
|
||||
let mut pictures = Vec::new();
|
||||
for (dst_i, &src_i) in order.iter().enumerate() {
|
||||
@@ -389,6 +404,18 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every cell of the body is the destination of exactly one source cell, so clearing the
|
||||
// body and inserting the permuted entries is the same permutation the cells went through.
|
||||
for c in body.iter() {
|
||||
shm.comments.remove(&c);
|
||||
shm.hyperlinks.remove(&c);
|
||||
}
|
||||
for (at, cm) in side {
|
||||
shm.comments.insert(at, cm);
|
||||
}
|
||||
for (at, h) in links {
|
||||
shm.hyperlinks.insert(at, h);
|
||||
}
|
||||
shm.cell_pictures.extend(pictures);
|
||||
for (i, h) in heights.into_iter().enumerate() {
|
||||
let row = body.start.row + i as u32;
|
||||
@@ -825,6 +852,24 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone())))
|
||||
})
|
||||
.collect();
|
||||
// Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that
|
||||
// describe them move with them; the ones on removed rows are dropped with the row.
|
||||
let notes: Vec<_> = keep
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(r.start.col..=r.end.col)
|
||||
.filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone())))
|
||||
})
|
||||
.collect();
|
||||
let links: Vec<_> = keep
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(r.start.col..=r.end.col)
|
||||
.filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone())))
|
||||
})
|
||||
.collect();
|
||||
let rows: Vec<Vec<Option<Cell>>> =
|
||||
keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect();
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
@@ -833,6 +878,13 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
shm.remove_cell(CellRef::new(row, c));
|
||||
}
|
||||
}
|
||||
for row in start..=r.end.row {
|
||||
for c in r.start.col..=r.end.col {
|
||||
let at = CellRef::new(row, c);
|
||||
shm.comments.remove(&at);
|
||||
shm.hyperlinks.remove(&at);
|
||||
}
|
||||
}
|
||||
for (i, cells) in rows.into_iter().enumerate() {
|
||||
for (j, cell) in cells.into_iter().enumerate() {
|
||||
if let Some(cell) = cell {
|
||||
@@ -841,6 +893,12 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
shm.cell_pictures.extend(pictures);
|
||||
for (at, cm) in notes {
|
||||
shm.comments.insert(at, cm);
|
||||
}
|
||||
for (at, h) in links {
|
||||
shm.hyperlinks.insert(at, h);
|
||||
}
|
||||
for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() {
|
||||
cx.changed.push((sheet, c));
|
||||
}
|
||||
|
||||
@@ -1003,6 +1003,62 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::
|
||||
pg.restore();
|
||||
}
|
||||
|
||||
struct PendingPdfLink {
|
||||
page: usize,
|
||||
sheet: usize,
|
||||
rect: (f32, f32, f32, f32),
|
||||
target: String,
|
||||
}
|
||||
|
||||
struct PrintedBlock {
|
||||
page: usize,
|
||||
sheet: usize,
|
||||
rows: (u32, u32),
|
||||
cols: (u32, u32),
|
||||
x: f32,
|
||||
y: f32,
|
||||
k: f32,
|
||||
}
|
||||
|
||||
/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page
|
||||
/// block before adding a clickable rectangle. A repeated print-title cell may appear on several
|
||||
/// pages and receives one annotation on each of them.
|
||||
fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec<PendingPdfLink>) {
|
||||
let range = b.range();
|
||||
let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return };
|
||||
for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) {
|
||||
if !range.contains(*cell) {
|
||||
continue;
|
||||
}
|
||||
let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell));
|
||||
let (x, y, w, h) = b.rect(cell_range);
|
||||
let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom));
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
continue;
|
||||
}
|
||||
let rect = (x0, y0, x1 - x0, y1 - y0);
|
||||
match crate::links::classify(&link.target) {
|
||||
crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri),
|
||||
crate::links::LinkKind::Internal(target) if !link.external => {
|
||||
pending.push(PendingPdfLink { page, sheet: b.si, rect, target });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> {
|
||||
let target = target.trim().trim_start_matches('#').trim_start_matches('=');
|
||||
let (sheet_name, range) = split_sheet(target);
|
||||
let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?;
|
||||
let cell = RangeRef::parse(&range.replace('$', ""))?.start;
|
||||
let sh = wb.sheet(sheet)?;
|
||||
let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?;
|
||||
let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k;
|
||||
let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k;
|
||||
Some((block.page, x, y))
|
||||
}
|
||||
|
||||
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
|
||||
fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
|
||||
let (x, y, w, h) = rect;
|
||||
@@ -1470,6 +1526,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
let total: usize = layouts.iter().map(|l| l.pages.len()).sum();
|
||||
let (date, time) = hf_date_time(wb);
|
||||
let mut page_no = 0usize;
|
||||
let mut pending_links = Vec::new();
|
||||
let mut printed_blocks = Vec::new();
|
||||
for lay in layouts {
|
||||
let Some(sh) = wb.sheet(lay.sheet) else { continue };
|
||||
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
|
||||
@@ -1484,6 +1542,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
let content_h = (lay.heading_h + rsegs.iter().map(|s| seg_size(sh, *s, true)).sum::<f32>()) * k;
|
||||
let ox = lay.margins[0] + if lay.settings.center_h { ((aw - content_w) / 2.0).max(0.0) } else { 0.0 };
|
||||
let oy = lay.margins[2] + if lay.settings.center_v { ((ah - content_h) / 2.0).max(0.0) } else { 0.0 };
|
||||
let page_index = doc.page_count();
|
||||
let pg = doc.add_page(lay.page_w, lay.page_h);
|
||||
let mut y = oy + lay.heading_h * k;
|
||||
let mut body: Option<Block> = None;
|
||||
@@ -1492,6 +1551,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
for (csi, cs) in csegs.iter().enumerate() {
|
||||
let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);
|
||||
draw_block(pg, &b, lay.settings.gridlines, &mut cf);
|
||||
draw_block_links(pg, &b, page_index, &mut pending_links);
|
||||
printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });
|
||||
if lay.settings.headings {
|
||||
draw_headings(pg, &b, rsi == 0, csi == 0, (ox, oy), (lay.heading_w * k, lay.heading_h * k));
|
||||
}
|
||||
@@ -1522,6 +1583,14 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx);
|
||||
}
|
||||
}
|
||||
for link in pending_links {
|
||||
if let Some((target_page, x, y)) = printed_target(wb, &printed_blocks, link.sheet, &link.target)
|
||||
&& let Some(page) = doc.page_mut(link.page)
|
||||
{
|
||||
let (lx, ly, w, h) = link.rect;
|
||||
page.link_page(lx, ly, w, h, target_page, x, y);
|
||||
}
|
||||
}
|
||||
let n = doc.page_count();
|
||||
(doc.finish(), n)
|
||||
}
|
||||
@@ -1767,6 +1836,29 @@ mod tests {
|
||||
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_export_keeps_safe_links_on_their_printed_pages() {
|
||||
let mut s = s();
|
||||
s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap();
|
||||
s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!A60"})).unwrap();
|
||||
s.execute("insert.link", json!({"cell": "A3", "target": "file:///private/report"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A60", "input": "Destination"})).unwrap();
|
||||
let (pdf, pages) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(pages, 2);
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
assert_eq!(text.matches("/Subtype /Link").count(), 2);
|
||||
assert!(text.contains("/URI (https://example.org/report)"));
|
||||
assert!(text.contains("/Dest [") && text.contains("/XYZ "));
|
||||
assert!(!text.contains("/URI (file:"));
|
||||
|
||||
// A link to a cell omitted by the selected print range must not point to a nonexistent
|
||||
// destination. The external link in the selected range remains clickable.
|
||||
let (cropped, _) = pdf_of(&mut s, json!({"range": "A1:A3"}));
|
||||
let cropped = String::from_utf8_lossy(&cropped);
|
||||
assert_eq!(cropped.matches("/Subtype /Link").count(), 1);
|
||||
assert!(!cropped.contains("/Dest ["));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_sections_split_join_and_field_names() {
|
||||
let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D";
|
||||
@@ -1902,6 +1994,16 @@ mod tests {
|
||||
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_prints_cjk_text_instead_of_question_marks() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["签字清单", 2]]})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert!(!contains(&pdf, "(????) Tj"), "CJK cell printed as (????) Tj");
|
||||
assert!(contains(&pdf, "<7B7E5B576E055355> Tj"));
|
||||
assert!(contains(&pdf, "(2) Tj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_contains_text_and_header_codes_expand() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -265,13 +265,31 @@ fn print_area(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn print_titles(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let rows = str_param(p, "rows").and_then(RangeRef::parse).map(|r| (r.start.row, r.end.row));
|
||||
let cols = str_param(p, "cols").and_then(RangeRef::parse).map(|r| (r.start.col, r.end.col));
|
||||
// An absent key leaves that axis alone, and an empty or null value clears it — the same
|
||||
// reading `pageLayout.pageSetup` gives `titleRows`/`titleCols`. Assigning `None` for a
|
||||
// key that was never sent used to wipe the other axis.
|
||||
let axis = |key: &str, is_cols: bool| -> Result<Option<Option<(u32, u32)>>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Json::Null) => Ok(Some(None)),
|
||||
Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)),
|
||||
Some(Json::String(t)) => match RangeRef::parse(t) {
|
||||
Some(r) => Ok(Some(Some(if is_cols { (r.start.col, r.end.col) } else { (r.start.row, r.end.row) }))),
|
||||
None => Err(bad("pageLayout.printTitles", format!("`{key}` is not a range: `{t}`"))),
|
||||
},
|
||||
Some(_) => Err(bad("pageLayout.printTitles", format!("`{key}` must be text like \"1:2\" or \"A:A\""))),
|
||||
}
|
||||
};
|
||||
let (rows, cols) = (axis("rows", false)?, axis("cols", true)?);
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let pr = &mut cx.sheet_mut(si)?.print;
|
||||
pr.title_rows = rows;
|
||||
pr.title_cols = cols;
|
||||
if let Some(t) = rows {
|
||||
pr.title_rows = t;
|
||||
}
|
||||
if let Some(t) = cols {
|
||||
pr.title_cols = t;
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Advanced…",
|
||||
["Data", "Sort & Filter"],
|
||||
None,
|
||||
"{range? (list with headers; default current region), criteria: \"H1:I3\" (header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
|
||||
"{range? (list with headers; default current region), criteria?: \"H1:I3\" (omitted/blank matches all rows; header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
|
||||
has_doc,
|
||||
advanced_filter
|
||||
),
|
||||
@@ -802,41 +802,45 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
(d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active))
|
||||
}
|
||||
};
|
||||
let (csi, crit) = range_arg(s, p, "criteria", CMD)?;
|
||||
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
|
||||
if list.height() < 1 || list.count() > 50_000_000 {
|
||||
return Err(bad(CMD, "the list range is too large"));
|
||||
}
|
||||
if crit.width() > 1024 || crit.height() > 10_000 {
|
||||
if let Some((_, crit)) = criteria
|
||||
&& (crit.width() > 1024 || crit.height() > 10_000)
|
||||
{
|
||||
return Err(bad(CMD, "the criteria range is too large"));
|
||||
}
|
||||
let wb = &d.wb;
|
||||
let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
|
||||
let headers: Vec<String> =
|
||||
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect();
|
||||
let first_data = list.start.row + 1;
|
||||
// Criteria rows → conditions.
|
||||
let mut rows: Vec<Vec<Cond>> = Vec::new();
|
||||
for cr in crit.start.row + 1..=crit.end.row {
|
||||
let mut conds = Vec::new();
|
||||
for cc in crit.start.col..=crit.end.col {
|
||||
let at = CellRef::new(cr, cc);
|
||||
let cell = csh.cell(at);
|
||||
let v = csh.value(at);
|
||||
let formula = cell.and_then(|c| c.formula.as_ref());
|
||||
if v.is_empty() && formula.is_none() {
|
||||
continue;
|
||||
}
|
||||
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
|
||||
match headers.iter().position(|h| !head.is_empty() && *h == head) {
|
||||
Some(off) => conds.push(Cond::Compare(off as u32, v)),
|
||||
None => match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => conds.push(Cond::Computed(e, at)),
|
||||
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
|
||||
},
|
||||
if let Some((csi, crit)) = criteria {
|
||||
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
|
||||
for cr in crit.start.row + 1..=crit.end.row {
|
||||
let mut conds = Vec::new();
|
||||
for cc in crit.start.col..=crit.end.col {
|
||||
let at = CellRef::new(cr, cc);
|
||||
let cell = csh.cell(at);
|
||||
let v = csh.value(at);
|
||||
let formula = cell.and_then(|c| c.formula.as_ref());
|
||||
if v.is_empty() && formula.is_none() {
|
||||
continue;
|
||||
}
|
||||
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
|
||||
match headers.iter().position(|h| !head.is_empty() && *h == head) {
|
||||
Some(off) => conds.push(Cond::Compare(off as u32, v)),
|
||||
None => match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => conds.push(Cond::Computed(e, at)),
|
||||
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
|
||||
},
|
||||
}
|
||||
}
|
||||
rows.push(conds);
|
||||
}
|
||||
rows.push(conds);
|
||||
}
|
||||
let matches_row = |row: u32| -> bool {
|
||||
if rows.is_empty() {
|
||||
@@ -852,7 +856,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
Cond::Computed(e, at) => {
|
||||
let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0);
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(wb, csi, *at, &shifted);
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted);
|
||||
matches!(v.scalar().to_bool(), Ok(true))
|
||||
}
|
||||
})
|
||||
@@ -874,28 +878,30 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(t) = str_param(p, "copyTo") {
|
||||
let (dsi, dest) = parse_range(wb, t, CMD)?;
|
||||
let dest = dest.start;
|
||||
let extract = RangeRef::new(dest, dest.offset_clamped(list.height() as i64 - 1, list.width() as i64 - 1));
|
||||
if dsi == si && extract.intersects(&list) {
|
||||
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
|
||||
}
|
||||
let mut rows_out: Vec<u32> = vec![list.start.row];
|
||||
rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r));
|
||||
let end =
|
||||
dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?;
|
||||
let out_range = RangeRef::new(dest, end);
|
||||
if dsi == si && out_range.intersects(&list) {
|
||||
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
|
||||
}
|
||||
let cells: Vec<(CellRef, Cell)> = rows_out
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(list.start.col..=list.end.col).filter_map(move |c| {
|
||||
(list.start.col..=list.end.col).map(move |c| {
|
||||
let src = CellRef::new(*row, c);
|
||||
let to = dest.offset(i as i64, (c - list.start.col) as i64)?;
|
||||
// The full output footprint was checked above.
|
||||
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
|
||||
let style = sh.style_id(src);
|
||||
Some((to, Cell { value: sh.value(src), formula: None, style }))
|
||||
(to, Cell { value: sh.value(src), formula: None, style })
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let out_range = RangeRef::new(dest, dest.offset_clamped(rows_out.len() as i64 - 1, list.width() as i64 - 1));
|
||||
edit(s, |cx| {
|
||||
let shm = cx.sheet_mut(dsi)?;
|
||||
let old: Vec<CellRef> = shm.cells.iter_range(extract).map(|(c, _)| c).collect();
|
||||
let old: Vec<CellRef> = shm.cells.iter_range(out_range).map(|(c, _)| c).collect();
|
||||
for c in &old {
|
||||
shm.remove_cell(*c);
|
||||
}
|
||||
@@ -1335,6 +1341,52 @@ mod tests {
|
||||
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3", "copyTo": "B2"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_copy_preserves_cells_beyond_result() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "K4", "input": "Keep me"})).unwrap();
|
||||
let r = s.execute("data.advancedFilter", json!({"range": "A1:B4", "criteria": "H1:H2", "copyTo": "K1"})).unwrap();
|
||||
assert_eq!(r["range"], "K1:L2");
|
||||
assert_eq!(v(&s, "K2"), Value::from("B"));
|
||||
assert_eq!(v(&s, "K4"), Value::from("Keep me"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_copy_rejects_overflow_without_mutation() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
|
||||
for dest in ["XFD1", "K1048576"] {
|
||||
s.execute("cell.set", json!({"cell": dest, "input": "Keep me"})).unwrap();
|
||||
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B2", "criteria": "H1:H2", "copyTo": dest})).is_err());
|
||||
assert_eq!(v(&s, dest), Value::from("Keep me"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_copy_allows_output_above_source_when_only_old_clear_area_overlaps() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "C4", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
|
||||
let r = s.execute("data.advancedFilter", json!({"range": "C4:D7", "criteria": "H1:H2", "copyTo": "B1"})).unwrap();
|
||||
assert_eq!(r["range"], "B1:C2");
|
||||
assert_eq!(v(&s, "C4"), Value::from("Name"));
|
||||
assert_eq!(v(&s, "C2"), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_unique_without_criteria() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap();
|
||||
let r = s.execute("data.advancedFilter", json!({"range": "A1:A4", "copyTo": "C1", "unique": true})).unwrap();
|
||||
assert_eq!(r["matched"], 2);
|
||||
assert_eq!(r["range"], "C1:C3");
|
||||
assert_eq!(v(&s, "C2"), Value::from("A"));
|
||||
assert_eq!(v(&s, "C3"), Value::from("B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn criteria_semantics() {
|
||||
use super::criterion_matches as m;
|
||||
|
||||
@@ -3243,6 +3243,74 @@ fn a_cut_moves_the_sources_notes_hyperlinks_and_merges() {
|
||||
assert!(v(&s, "A1").is_empty() && v(&s, "B1").is_empty());
|
||||
}
|
||||
|
||||
/// Page Layout ▸ Print Titles: naming one axis must not clear the other. `print_titles` read
|
||||
/// both `rows` and `cols` into `Option`s and assigned both unconditionally, so
|
||||
/// `{"rows":"2:2"}` silently dropped the repeated columns from every printed page.
|
||||
#[test]
|
||||
fn print_titles_keeps_the_axis_it_is_not_given() {
|
||||
let mut s = s();
|
||||
s.execute("pageLayout.printTitles", json!({"rows": "1:1", "cols": "A:A"})).unwrap();
|
||||
let titles = |s: &Session| {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
(sh.print.title_rows, sh.print.title_cols)
|
||||
};
|
||||
assert_eq!(titles(&s), (Some((0, 0)), Some((0, 0))));
|
||||
// Only rows: the columns stay.
|
||||
s.execute("pageLayout.printTitles", json!({"rows": "2:2"})).unwrap();
|
||||
assert_eq!(titles(&s), (Some((1, 1)), Some((0, 0))), "rows alone must not clear the columns");
|
||||
// Only columns: the rows stay.
|
||||
s.execute("pageLayout.printTitles", json!({"cols": "B:B"})).unwrap();
|
||||
assert_eq!(titles(&s), (Some((1, 1)), Some((1, 1))), "columns alone must not clear the rows");
|
||||
// An empty value clears that axis on purpose.
|
||||
s.execute("pageLayout.printTitles", json!({"cols": ""})).unwrap();
|
||||
assert_eq!(titles(&s), (Some((1, 1)), None));
|
||||
// A bad range is an error, not a silent wipe.
|
||||
assert!(s.execute("pageLayout.printTitles", json!({"rows": "not a range"})).is_err());
|
||||
assert_eq!(titles(&s), (Some((1, 1)), None));
|
||||
}
|
||||
|
||||
/// Sorting and removing duplicates rewrite the rows of a range. The cells, pictures, row heights
|
||||
/// and anchors travel with them, but the notes and hyperlinks hung off their old cells: a note
|
||||
/// describing one record ended up annotating whatever landed on that row, and on a removed row it
|
||||
/// annotated blank cells.
|
||||
#[test]
|
||||
fn sort_and_remove_duplicates_move_the_rows_notes_and_links() {
|
||||
fn setup(values: [&str; 5]) -> Session {
|
||||
let mut s = s();
|
||||
for (row, text) in values.iter().enumerate() {
|
||||
s.execute("cell.set", json!({"cell": format!("A{}", row + 1), "input": text})).unwrap();
|
||||
}
|
||||
s.execute("review.newNote", json!({"cell": "B5", "text": "late", "author": "Example"})).unwrap();
|
||||
s.execute("insert.link", json!({"cell": "B4", "target": "https://example.com/4"})).unwrap();
|
||||
s
|
||||
}
|
||||
let notes_and_links = |s: &Session| {
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
let cells = ["B2", "B3", "B4", "B5"].map(|c| CellRef::parse(c).unwrap());
|
||||
(cells.map(|c| sh.comments.get(&c).map(|m| m.text.clone())), cells.map(|c| sh.hyperlinks.get(&c).map(|h| h.target.clone())))
|
||||
};
|
||||
|
||||
// Sort ascending with unique keys d,a,e,b,c: the order becomes a(2), b(4), c(5), d(1), e(3).
|
||||
// The note on B5 describes row 5's record ("c"), which lands on row 3; the link on B4
|
||||
// describes row 4's record ("b"), which lands on row 2. Both must travel with their record.
|
||||
let mut s = setup(["d", "a", "e", "b", "c"]);
|
||||
s.execute("data.sort", json!({"range": "A1:B5", "keys": [{"column": "A"}]})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::text("c"));
|
||||
assert_eq!(v(&s, "A5"), Value::text("e"));
|
||||
let (notes, links) = notes_and_links(&s);
|
||||
assert_eq!(notes[1], Some("late".to_string()), "the note moved with its record: {notes:?}");
|
||||
assert_eq!(links[0], Some("https://example.com/4".to_string()), "the link moved with its record: {links:?}");
|
||||
|
||||
// Remove duplicates keeps the first of each value: rows 3 and 5 go, and row 4's record moves
|
||||
// to row 3. The note on B5 described row 5 and goes with it.
|
||||
let mut s = setup(["a", "b", "a", "c", "a"]);
|
||||
s.execute("data.removeDuplicates", json!({"range": "A1:B5"})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::text("c"));
|
||||
let (notes, _) = notes_and_links(&s);
|
||||
assert!(notes.iter().all(Option::is_none), "a note on a removed row goes with it: {notes:?}");
|
||||
}
|
||||
|
||||
/// Insert › Link sorts a target as Excel's dialog does: a sheet reference, a defined name or `#…` is
|
||||
/// a place in this document, anything else an existing file or web page. A file saved as a
|
||||
/// `location` opens nothing in Excel (#800).
|
||||
|
||||
+173
-24
@@ -1,7 +1,9 @@
|
||||
//! A tiny, self-contained PDF 1.7 writer for GridCraft's print and PDF export.
|
||||
//!
|
||||
//! - The 14 standard fonts (no embedding) with WinAnsiEncoding, plus approximate width tables
|
||||
//! ([`text_width`]) so callers can measure, align and wrap text.
|
||||
//! ([`text_width`]) so callers can measure, align and wrap text. Characters outside
|
||||
//! WinAnsiEncoding (CJK and other scripts) are set in a predefined, non-embedded Adobe-GB1 CID
|
||||
//! font with the `UniGB-UTF16-H` CMap instead of being replaced by `?`.
|
||||
//! - Vector drawing: rectangles, lines (solid or dashed), polygons, circles (Bézier), clipping.
|
||||
//! - RGBA images as RGB image XObjects with a soft mask for alpha (run-length encoded).
|
||||
//! - Coordinates are points with a **top-left origin** (y grows down); text `y` is the baseline.
|
||||
@@ -125,6 +127,9 @@ impl Font {
|
||||
Font::TimesBold | Font::TimesBoldItalic => Some(&metrics::TIMES_BOLD),
|
||||
_ => None,
|
||||
};
|
||||
if needs_cid(c) {
|
||||
return CID_WIDTH;
|
||||
}
|
||||
let Some(table) = table else { return 600 };
|
||||
let ascii = |c: char| -> Option<u16> {
|
||||
let i = (c as u32).checked_sub(32)? as usize;
|
||||
@@ -189,6 +194,31 @@ pub fn win_ansi(c: char) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the CID font in a page's font flags (after the [`Font::ALL`] faces).
|
||||
const CID_FONT: usize = Font::ALL.len();
|
||||
/// Number of font resources a page can use.
|
||||
const FONT_SLOTS: usize = CID_FONT + 1;
|
||||
/// Base font of the CID font used for characters WinAnsiEncoding lacks.
|
||||
const CID_BASE_FONT: &str = "STSong-Light";
|
||||
/// Default glyph width of the CID font (full-width), in 1/1000 em.
|
||||
const CID_WIDTH: u16 = 1000;
|
||||
|
||||
/// True when `c` has no WinAnsiEncoding byte and is drawn with the CID font.
|
||||
fn needs_cid(c: char) -> bool {
|
||||
!c.is_control() && c != '?' && win_ansi(c) == b'?'
|
||||
}
|
||||
|
||||
/// A PDF hex string `< … >` of `text` in UTF-16BE (the `UniGB-UTF16-H` encoding).
|
||||
fn pdf_utf16_string(text: &str) -> String {
|
||||
let mut s = String::with_capacity(text.len() * 4 + 2);
|
||||
s.push('<');
|
||||
for unit in text.encode_utf16() {
|
||||
let _ = write!(s, "{unit:04X}");
|
||||
}
|
||||
s.push('>');
|
||||
s
|
||||
}
|
||||
|
||||
/// A PDF literal string `( … )` for `text` in WinAnsiEncoding.
|
||||
fn pdf_string(text: &str) -> String {
|
||||
let mut s = String::with_capacity(text.len() + 2);
|
||||
@@ -210,6 +240,19 @@ fn pdf_string(text: &str) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// URI actions use byte strings. Percent-encode non-ASCII UTF-8 before PDF string escaping.
|
||||
fn pdf_uri_string(uri: &str) -> String {
|
||||
let mut ascii = String::with_capacity(uri.len());
|
||||
for byte in uri.bytes() {
|
||||
if byte.is_ascii() {
|
||||
ascii.push(byte as char);
|
||||
} else {
|
||||
let _ = write!(ascii, "%{byte:02X}");
|
||||
}
|
||||
}
|
||||
pdf_string(&ascii)
|
||||
}
|
||||
|
||||
/// A number for a content stream: finite, short.
|
||||
fn n(v: f32) -> String {
|
||||
let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 };
|
||||
@@ -260,13 +303,25 @@ struct ImageData {
|
||||
alpha: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
enum LinkTarget {
|
||||
Uri(String),
|
||||
Page { index: usize, x: f32, y: f32 },
|
||||
}
|
||||
|
||||
struct LinkAnnotation {
|
||||
/// PDF coordinates, with the origin at the bottom left.
|
||||
rect: [f32; 4],
|
||||
target: LinkTarget,
|
||||
}
|
||||
|
||||
/// A page under construction. Obtain one with [`PdfDoc::add_page`].
|
||||
pub struct Page {
|
||||
width: f32,
|
||||
height: f32,
|
||||
content: String,
|
||||
fonts: [bool; 12],
|
||||
fonts: [bool; FONT_SLOTS],
|
||||
images: Vec<ImageData>,
|
||||
links: Vec<LinkAnnotation>,
|
||||
depth: u32,
|
||||
}
|
||||
|
||||
@@ -275,6 +330,8 @@ pub struct Page {
|
||||
const MAX_PAGE_CONTENT: usize = 64 * 1024 * 1024;
|
||||
/// Largest image (pixels) embedded.
|
||||
const MAX_IMAGE_PIXELS: u64 = 40_000_000;
|
||||
const MAX_PAGE_LINKS: usize = 4096;
|
||||
const MAX_LINK_URI_BYTES: usize = 4096;
|
||||
|
||||
impl Page {
|
||||
pub fn width(&self) -> f32 {
|
||||
@@ -296,6 +353,42 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
fn link_rect(&self, x: f32, y: f32, w: f32, h: f32) -> Option<[f32; 4]> {
|
||||
if !finite(&[x, y, w, h]) || !extent(w) || !extent(h) || self.links.len() >= MAX_PAGE_LINKS {
|
||||
return None;
|
||||
}
|
||||
let (right, bottom) = (x + w, y + h);
|
||||
if !finite(&[right, bottom]) {
|
||||
return None;
|
||||
}
|
||||
let (left, right) = (x.max(0.0), right.min(self.width));
|
||||
let (top, bottom) = (y.max(0.0), bottom.min(self.height));
|
||||
(right > left && bottom > top).then(|| [left, self.y(bottom), right, self.y(top)])
|
||||
}
|
||||
|
||||
/// Adds an invisible clickable area for an external URI. Invalid or excessive links are ignored.
|
||||
pub fn link_uri(&mut self, x: f32, y: f32, w: f32, h: f32, uri: &str) {
|
||||
let allowed = uri
|
||||
.split_once(':')
|
||||
.is_some_and(|(scheme, rest)| matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https" | "mailto") && !rest.is_empty());
|
||||
if !allowed || uri.len() > MAX_LINK_URI_BYTES || uri.chars().any(char::is_control) {
|
||||
return;
|
||||
}
|
||||
if let Some(rect) = self.link_rect(x, y, w, h) {
|
||||
self.links.push(LinkAnnotation { rect, target: LinkTarget::Uri(uri.to_string()) });
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a link to a location on another page of this document (zero-based page index).
|
||||
pub fn link_page(&mut self, x: f32, y: f32, w: f32, h: f32, page: usize, target_x: f32, target_y: f32) {
|
||||
if !finite(&[target_x, target_y]) {
|
||||
return;
|
||||
}
|
||||
if let Some(rect) = self.link_rect(x, y, w, h) {
|
||||
self.links.push(LinkAnnotation { rect, target: LinkTarget::Page { index: page, x: target_x, y: target_y } });
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the graphics state (pair with [`Page::restore`]).
|
||||
pub fn save(&mut self) {
|
||||
self.depth = self.depth.saturating_add(1);
|
||||
@@ -460,24 +553,25 @@ impl Page {
|
||||
return;
|
||||
}
|
||||
let fi = font.index();
|
||||
if let Some(f) = self.fonts.get_mut(fi) {
|
||||
*f = true;
|
||||
}
|
||||
let size = n(size.min(10_000.0));
|
||||
let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) };
|
||||
// Clockwise in y-down = counter-clockwise negated in PDF's y-up space.
|
||||
let s = format!(
|
||||
"BT\n{}\n/F{} {} Tf\n{} {} {} {} {} {} Tm\n{} Tj\nET",
|
||||
rgb_op(color, false),
|
||||
fi + 1,
|
||||
n(size.min(10_000.0)),
|
||||
n(cs),
|
||||
n(-sn),
|
||||
n(sn),
|
||||
n(cs),
|
||||
n(x),
|
||||
n(self.y(y)),
|
||||
pdf_string(text)
|
||||
);
|
||||
let mut s = format!("BT\n{}\n{} {} {} {} {} {} Tm\n", rgb_op(color, false), n(cs), n(-sn), n(sn), n(cs), n(x), n(self.y(y)));
|
||||
// Runs of WinAnsi text use `font`; runs it cannot encode use the CID font. Each `Tj`
|
||||
// advances the text position, so the runs follow each other on the baseline.
|
||||
let mut rest = text;
|
||||
while let Some(first) = rest.chars().next() {
|
||||
let cid = needs_cid(first);
|
||||
let end = rest.char_indices().find(|(_, c)| needs_cid(*c) != cid).map_or(rest.len(), |(i, _)| i);
|
||||
let (run, tail) = rest.split_at(end);
|
||||
let (slot, string) = if cid { (CID_FONT, pdf_utf16_string(run)) } else { (fi, pdf_string(run)) };
|
||||
if let Some(f) = self.fonts.get_mut(slot) {
|
||||
*f = true;
|
||||
}
|
||||
let _ = writeln!(s, "/F{} {} Tf\n{} Tj", slot + 1, size, string);
|
||||
rest = tail;
|
||||
}
|
||||
s.push_str("ET");
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
@@ -583,7 +677,7 @@ impl PdfDoc {
|
||||
pub fn add_page(&mut self, width_pt: f32, height_pt: f32) -> &mut Page {
|
||||
let width = if width_pt.is_finite() { width_pt.clamp(3.0, 14_400.0) } else { 612.0 };
|
||||
let height = if height_pt.is_finite() { height_pt.clamp(3.0, 14_400.0) } else { 792.0 };
|
||||
self.pages.push(Page { width, height, content: String::new(), fonts: [false; 12], images: Vec::new(), depth: 0 });
|
||||
self.pages.push(Page { width, height, content: String::new(), fonts: [false; FONT_SLOTS], images: Vec::new(), links: Vec::new(), depth: 0 });
|
||||
let i = self.pages.len() - 1;
|
||||
&mut self.pages[i]
|
||||
}
|
||||
@@ -603,26 +697,54 @@ impl PdfDoc {
|
||||
self.add_page(612.0, 792.0);
|
||||
}
|
||||
// Object numbering: 1 catalog, 2 page tree, 3 info, then fonts, then per page.
|
||||
let mut used = [false; 12];
|
||||
let mut used = [false; FONT_SLOTS];
|
||||
for p in &self.pages {
|
||||
for (u, f) in used.iter_mut().zip(p.fonts.iter()) {
|
||||
*u |= *f;
|
||||
}
|
||||
}
|
||||
let mut next = 4u32;
|
||||
let mut font_ids = [0u32; 12];
|
||||
let mut font_ids = [0u32; FONT_SLOTS];
|
||||
for (i, u) in used.iter().enumerate() {
|
||||
if *u {
|
||||
font_ids[i] = next;
|
||||
next += 1;
|
||||
// The CID font also needs its descendant font and font descriptor.
|
||||
next += if i == CID_FONT { 3 } else { 1 };
|
||||
}
|
||||
}
|
||||
let mut objects: Vec<(u32, Vec<u8>)> = Vec::new();
|
||||
let mut kids = Vec::new();
|
||||
let mut annotations = Vec::new();
|
||||
let page_count = self.pages.len();
|
||||
let mut font_dict = String::new();
|
||||
for (i, id) in font_ids.iter().enumerate() {
|
||||
if *id != 0 {
|
||||
let _ = write!(font_dict, "/F{} {} 0 R ", i + 1, id);
|
||||
if i == CID_FONT {
|
||||
let (cid_id, desc_id) = (id + 1, id + 2);
|
||||
objects.push((
|
||||
*id,
|
||||
format!(
|
||||
"<< /Type /Font /Subtype /Type0 /BaseFont /{CID_BASE_FONT} /Encoding /UniGB-UTF16-H /DescendantFonts [{cid_id} 0 R] >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
));
|
||||
objects.push((
|
||||
cid_id,
|
||||
format!(
|
||||
"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /{CID_BASE_FONT} /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /FontDescriptor {desc_id} 0 R /DW {CID_WIDTH} >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
));
|
||||
objects.push((
|
||||
desc_id,
|
||||
format!(
|
||||
"<< /Type /FontDescriptor /FontName /{CID_BASE_FONT} /Flags 6 /FontBBox [-25 -254 1000 880] /ItalicAngle 0 /Ascent 880 /Descent -120 /CapHeight 880 /StemV 93 >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let name = Font::ALL.get(i).copied().unwrap_or_default().base_name();
|
||||
let enc = if name.starts_with("Symbol") { "" } else { " /Encoding /WinAnsiEncoding" };
|
||||
objects.push((*id, format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name}{enc} >>").into_bytes()));
|
||||
@@ -678,20 +800,47 @@ impl PdfDoc {
|
||||
o.extend_from_slice(b"endstream");
|
||||
objects.push((content_id, o));
|
||||
let xo = if xobjects.is_empty() { String::new() } else { format!(" /XObject << {xobjects}>>") };
|
||||
let mut link_refs = String::new();
|
||||
for link in std::mem::take(&mut page.links) {
|
||||
if matches!(&link.target, LinkTarget::Page { index, .. } if *index >= page_count) {
|
||||
continue;
|
||||
}
|
||||
let id = next;
|
||||
next += 1;
|
||||
let _ = write!(link_refs, "{id} 0 R ");
|
||||
annotations.push((id, link));
|
||||
}
|
||||
let annots = if link_refs.is_empty() { String::new() } else { format!(" /Annots [{link_refs}]") };
|
||||
objects.push((
|
||||
page_id,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R{} >>",
|
||||
n(page.width),
|
||||
n(page.height),
|
||||
font_dict,
|
||||
xo,
|
||||
content_id
|
||||
content_id,
|
||||
annots
|
||||
)
|
||||
.into_bytes(),
|
||||
));
|
||||
kids.push(page_id);
|
||||
}
|
||||
for (id, link) in annotations {
|
||||
let [x0, y0, x1, y1] = link.rect;
|
||||
let action = match link.target {
|
||||
LinkTarget::Uri(uri) => format!("/A << /S /URI /URI {} >>", pdf_uri_string(&uri)),
|
||||
LinkTarget::Page { index, x, y } => {
|
||||
let Some(&page_id) = kids.get(index) else { continue };
|
||||
let height = self.pages.get(index).map_or(0.0, |p| p.height);
|
||||
format!("/Dest [{page_id} 0 R /XYZ {} {} null]", n(x), n(height - y))
|
||||
}
|
||||
};
|
||||
objects.push((
|
||||
id,
|
||||
format!("<< /Type /Annot /Subtype /Link /Rect [{} {} {} {}] /Border [0 0 0] {action} >>", n(x0), n(y0), n(x1), n(y1)).into_bytes(),
|
||||
));
|
||||
}
|
||||
objects.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
|
||||
let kids_s: Vec<String> = kids.iter().map(|k| format!("{k} 0 R")).collect();
|
||||
objects.push((2, format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()).into_bytes()));
|
||||
|
||||
@@ -76,6 +76,29 @@ fn drawing_and_text() {
|
||||
assert!(find(&pdf, b"0 792 m 612 0 l S").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_and_page_links_are_real_pdf_annotations() {
|
||||
let mut d = PdfDoc::new();
|
||||
let first = d.add_page(200.0, 300.0);
|
||||
first.link_uri(10.0, 20.0, 80.0, 30.0, "https://example.org/a(b)?q=£");
|
||||
first.link_page(100.0, 40.0, 50.0, 20.0, 1, 35.0, 60.0);
|
||||
first.link_uri(-20.0, -20.0, 40.0, 40.0, "mailto:a@example.org");
|
||||
first.link_uri(0.0, 0.0, f32::NAN, 10.0, "https://bad.example");
|
||||
first.link_uri(0.0, 0.0, 10.0, 10.0, "javascript:alert(1)");
|
||||
let second = d.add_page(100.0, 200.0);
|
||||
second.link_page(0.0, 0.0, 10.0, 10.0, 99, 0.0, 0.0);
|
||||
let pdf = d.finish();
|
||||
check_xref(&pdf);
|
||||
let s = String::from_utf8_lossy(&pdf);
|
||||
assert_eq!(s.matches("/Subtype /Link").count(), 3);
|
||||
assert!(s.contains("/Annots ["));
|
||||
assert!(s.contains("/Rect [10 250 90 280]"));
|
||||
assert!(s.contains("/Rect [0 280 20 300]"));
|
||||
assert!(s.contains("/URI (https://example.org/a\\(b\\)?q=%C2%A3)"));
|
||||
assert!(s.contains("/XYZ 35 140 null"));
|
||||
assert!(!s.contains("https://bad.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_with_alpha() {
|
||||
let mut d = PdfDoc::new();
|
||||
@@ -277,3 +300,21 @@ fn info_strings_are_capped() {
|
||||
let pdf = doc.finish();
|
||||
assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_text_is_not_replaced_by_question_marks() {
|
||||
let mut doc = PdfDoc::new();
|
||||
let p = doc.add_page(612.0, 792.0);
|
||||
p.text(10.0, 20.0, 10.0, Font::Helvetica, [0, 0, 0], "签字清单");
|
||||
p.text(10.0, 40.0, 10.0, Font::Helvetica, [0, 0, 0], "No. 编号 2");
|
||||
let pdf = doc.finish();
|
||||
check_xref(&pdf);
|
||||
assert!(find(&pdf, b"(????) Tj").is_none(), "CJK text written as question marks");
|
||||
assert!(find(&pdf, b"<7B7E5B576E055355> Tj").is_some());
|
||||
assert!(find(&pdf, b"(No. ) Tj").is_some());
|
||||
assert!(find(&pdf, b"<7F1653F7> Tj").is_some());
|
||||
assert!(find(&pdf, b"( 2) Tj").is_some());
|
||||
assert!(find(&pdf, b"/Encoding /UniGB-UTF16-H").is_some());
|
||||
// Measured as full-width glyphs, matching the CID font's default width.
|
||||
assert_eq!(text_width(Font::Helvetica, 10.0, "签字"), 20.0);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ use std::fmt::Write as _;
|
||||
use serde_json::Value;
|
||||
|
||||
fn main() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
|
||||
// Read when the script runs: `env!` would keep the path from when the script was compiled,
|
||||
// which goes stale if the checkout moves (and a missing file reruns the script every build).
|
||||
let manifest = std::env::var_os("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from).unwrap_or_default();
|
||||
let dir = manifest.join("../../contributors");
|
||||
let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| {
|
||||
serde_json::from_str::<Value>(t).map_err(|e| e.to_string())
|
||||
})
|
||||
|
||||
@@ -263,7 +263,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
"ui.dialog.confirm" => match app.dialog.take() {
|
||||
Some(d) => match d.command {
|
||||
Some(cmd) => {
|
||||
let params = Json::Object(d.values.clone());
|
||||
let params = crate::dialogs::build_params(&d);
|
||||
let r = app.run(cmd, params);
|
||||
if r.is_err() {
|
||||
app.dialog = Some(d);
|
||||
|
||||
@@ -67,11 +67,16 @@ impl DesktopTheme {
|
||||
}
|
||||
}
|
||||
|
||||
/// The session-bus connection the watcher keeps between reads, opened on first use.
|
||||
/// The session-bus connection the watcher keeps between reads, opened on first use. Off Linux
|
||||
/// there is no bus to keep, and a unit struct rather than `()` keeps `let mut link = …` from
|
||||
/// reading as a unit binding, which `cargo xtask ci` refuses on those platforms.
|
||||
#[cfg(target_os = "linux")]
|
||||
type Link = Option<zbus::blocking::Connection>;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
type Link = ();
|
||||
#[derive(Default)]
|
||||
pub(crate) struct NoLink;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
type Link = NoLink;
|
||||
|
||||
/// Ask the portal once, over `link`'s connection (opened if absent, dropped if the bus went
|
||||
/// away). D-Bus calls block, so this belongs off the UI thread: the watcher's thread is its
|
||||
|
||||
+310
-36
@@ -51,13 +51,28 @@ impl Dialog {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// Sets a form value. In Zoom, a Magnification choice is also the percentage `view.zoom` stores.
|
||||
/// Sets a form value. In Zoom, a Magnification choice is also the percentage `view.zoom`
|
||||
/// stores, a typed percentage selects Custom, and Fit selection makes OK run
|
||||
/// `view.zoomToSelection` (its view size was captured when the dialog opened).
|
||||
pub(crate) fn set_value(&mut self, key: &str, value: Json) {
|
||||
if self.name == "zoom"
|
||||
&& key == "preset"
|
||||
&& let Some(p) = value.as_str().and_then(|s| s.parse::<f64>().ok())
|
||||
{
|
||||
self.values.insert("percent".into(), json!(p));
|
||||
if self.name == "zoom" {
|
||||
match key {
|
||||
"preset" => match value.as_str() {
|
||||
Some("fit") => self.command = Some("view.zoomToSelection"),
|
||||
Some(s) => {
|
||||
self.command = Some("view.zoom");
|
||||
if let Ok(p) = s.parse::<f64>() {
|
||||
self.values.insert("percent".into(), json!(p));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
"percent" => {
|
||||
self.command = Some("view.zoom");
|
||||
self.values.insert("preset".into(), json!(zoom_preset(value.as_f64().unwrap_or(0.0))));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.values.insert(key.into(), value);
|
||||
}
|
||||
@@ -405,7 +420,11 @@ impl Dialog {
|
||||
Text { key: "header", label: "Header (&L left, &C center, &R right; &P page, &N pages, &D date, &A sheet):" },
|
||||
Text { key: "footer", label: "Footer:" },
|
||||
],
|
||||
json!({"header": "", "footer": "&CPage &P of &N"}),
|
||||
{
|
||||
let sheet = app.session.active().and_then(|d| d.wb.active());
|
||||
let (header, footer) = sheet.map(|s| (s.print.header.clone(), s.print.footer.clone())).unwrap_or_default();
|
||||
json!({"header": header, "footer": footer})
|
||||
},
|
||||
),
|
||||
"pageSetup" => {
|
||||
// Opens on the active sheet's saved margins, so OK without edits rewrites them unchanged.
|
||||
@@ -446,9 +465,33 @@ impl Dialog {
|
||||
"removeDuplicates",
|
||||
"Remove Duplicates",
|
||||
"data.removeDuplicates",
|
||||
vec![Check { key: "header", label: "My data has headers" }, Text { key: "columnsText", label: "Columns (blank = all, e.g. A,C):" }],
|
||||
json!({"header": true}),
|
||||
vec![
|
||||
Text { key: "range", label: "Range:" },
|
||||
Check { key: "header", label: "My data has headers" },
|
||||
Text { key: "columnsText", label: "Columns (blank = all, e.g. A,C):" },
|
||||
],
|
||||
json!({"range": remove_duplicates_range(app, &sel), "header": true}),
|
||||
),
|
||||
"advancedFilter" => {
|
||||
let list = app
|
||||
.session
|
||||
.active()
|
||||
.and_then(|d| d.wb.active().map(|sh| gridcraft_engine::selection::current_region(sh, d.selection.active).a1()))
|
||||
.unwrap_or(sel.clone());
|
||||
let mut d = Dialog::form(
|
||||
"advancedFilter",
|
||||
"Advanced Filter",
|
||||
"data.advancedFilter",
|
||||
vec![],
|
||||
json!({"action": "inPlace", "range": list, "criteria": "", "copyTo": "", "unique": false}),
|
||||
);
|
||||
for key in ["action", "range", "criteria", "copyTo", "unique"] {
|
||||
if let Some(value) = params.get(key) {
|
||||
d.set_value(key, value.clone());
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
"goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})),
|
||||
"forecastSheet" => Dialog::form(
|
||||
"forecastSheet",
|
||||
@@ -534,20 +577,19 @@ impl Dialog {
|
||||
],
|
||||
json!({"groupBy": "A", "function": "sum"}),
|
||||
),
|
||||
"zoom" => Dialog::form(
|
||||
"zoom",
|
||||
"Zoom",
|
||||
"view.zoom",
|
||||
vec![
|
||||
Choice {
|
||||
key: "preset",
|
||||
label: "Magnification",
|
||||
options: vec![("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")],
|
||||
},
|
||||
Number { key: "percent", label: "Custom (%):" },
|
||||
],
|
||||
json!({"percent": app.session.active().and_then(|d| d.wb.active().map(|s| s.zoom)).unwrap_or(100)}),
|
||||
),
|
||||
"zoom" => {
|
||||
// The current zoom is pre-selected (Custom when it matches no preset). Fit
|
||||
// selection needs the grid's size, so it is captured here for `view.zoomToSelection`.
|
||||
let zoom = app.session.active().and_then(|d| d.wb.active().map(|s| s.zoom)).unwrap_or(100);
|
||||
let view = app.grid.cells_rect.unwrap_or(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1200.0, 700.0)));
|
||||
Dialog::form(
|
||||
"zoom",
|
||||
"Zoom",
|
||||
"view.zoom",
|
||||
vec![],
|
||||
json!({"percent": zoom, "preset": zoom_preset(zoom as f64), "viewWidth": view.width(), "viewHeight": view.height()}),
|
||||
)
|
||||
}
|
||||
"defineName" => {
|
||||
// Name Manager › Edit… passes the name being edited (`oldName`, `oldScope`) and
|
||||
// its current fields; New… and Define Name start from the selection.
|
||||
@@ -680,7 +722,11 @@ impl Dialog {
|
||||
}],
|
||||
json!({"kind": "blanks"}),
|
||||
),
|
||||
"sort" => Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": "A", "order": "asc"}]})),
|
||||
"sort" => {
|
||||
// Seed "Sort by" from the column the user is actually in (#1906).
|
||||
let col = app.session.active().map(|d| gridcraft_engine::core::col_to_letters(d.selection.active.col)).unwrap_or_else(|| "A".into());
|
||||
Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": col, "order": "asc"}]}))
|
||||
}
|
||||
"nameManager" => Dialog::custom("nameManager", "Name Manager", json!({})),
|
||||
"manageRules" => Dialog::custom("manageRules", "Conditional Formatting Rules Manager", json!({})),
|
||||
"commandSearch" => Dialog::custom("commandSearch", "Search Commands", json!({})),
|
||||
@@ -841,6 +887,22 @@ fn validation_defaults(app: &SheetApp) -> Json {
|
||||
v
|
||||
}
|
||||
|
||||
/// The range Remove Duplicates starts with. A block selection is used as given; with only the
|
||||
/// cursor placed, the table's data rows or the current region, as the command picks when it gets
|
||||
/// no range (a single-cell range would remove nothing).
|
||||
fn remove_duplicates_range(app: &SheetApp, sel: &str) -> String {
|
||||
let Some(d) = app.session.active() else { return sel.to_string() };
|
||||
let Some(sh) = d.wb.active() else { return sel.to_string() };
|
||||
if !d.selection.current().is_single() {
|
||||
return sel.to_string();
|
||||
}
|
||||
let range = match sh.table_at(d.selection.active) {
|
||||
Some(t) => t.data_range().map(|dr| gridcraft_engine::core::RangeRef::new(t.range.start, dr.end)).unwrap_or(t.range),
|
||||
None => gridcraft_engine::selection::current_region(sh, d.selection.active),
|
||||
};
|
||||
range.a1()
|
||||
}
|
||||
|
||||
fn abs(r: &gridcraft_engine::core::RangeRef) -> String {
|
||||
let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1);
|
||||
if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) }
|
||||
@@ -950,10 +1012,44 @@ fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, o
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
use Field::*;
|
||||
let copy = d.values.get("action").and_then(Json::as_str) == Some("copy");
|
||||
d.fields = vec![
|
||||
Choice {
|
||||
key: "action",
|
||||
label: "Action",
|
||||
options: vec![("inPlace", "Show matching rows in place"), ("copy", "Copy matching rows elsewhere")],
|
||||
},
|
||||
Text { key: "range", label: "List range:" },
|
||||
Text { key: "criteria", label: "Criteria range (optional):" },
|
||||
];
|
||||
if copy {
|
||||
d.fields.push(Text { key: "copyTo", label: "Copy to:" });
|
||||
}
|
||||
d.fields.push(Check { key: "unique", label: "Unique rows only" });
|
||||
form_fields(ui, d);
|
||||
// Constrain the button row: the shared right-to-left layout otherwise
|
||||
// consumes the window's remaining height instead of fitting the buttons.
|
||||
ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open));
|
||||
}
|
||||
|
||||
/// Builds the command params from form values for dialogs that need translation.
|
||||
fn build_params(d: &Dialog) -> Json {
|
||||
/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog.
|
||||
pub(crate) fn build_params(d: &Dialog) -> Json {
|
||||
let mut v = Json::Object(d.values.clone());
|
||||
match d.name.as_str() {
|
||||
"advancedFilter" => {
|
||||
if let Some(m) = v.as_object_mut() {
|
||||
m.remove("action");
|
||||
if d.values.get("action").and_then(Json::as_str) != Some("copy") {
|
||||
m.remove("copyTo");
|
||||
}
|
||||
if m.get("criteria").and_then(Json::as_str).is_some_and(|s| s.trim().is_empty()) {
|
||||
m.remove("criteria");
|
||||
}
|
||||
}
|
||||
}
|
||||
"dataValidation" => {
|
||||
let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any");
|
||||
let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between");
|
||||
@@ -995,13 +1091,6 @@ fn build_params(d: &Dialog) -> Json {
|
||||
}
|
||||
}
|
||||
}
|
||||
"zoom" => {
|
||||
if let Some(p) = d.values.get("preset").and_then(Json::as_str)
|
||||
&& d.values.get("percent").is_none()
|
||||
{
|
||||
v["percent"] = json!(p.parse::<f64>().unwrap_or(100.0));
|
||||
}
|
||||
}
|
||||
"cfQuick" => {
|
||||
let mut rule = Map::new();
|
||||
for (k, val) in &d.values {
|
||||
@@ -1045,6 +1134,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"recommendedCharts" => 600.0,
|
||||
"analysisToolPak" => 420.0,
|
||||
"solver" => 460.0,
|
||||
"zoom" => 220.0,
|
||||
"pasteSpecial" => 480.0,
|
||||
_ => 380.0,
|
||||
};
|
||||
let window = egui::Window::new(app.ui.language.tr(&d.title))
|
||||
@@ -1052,17 +1143,25 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(width)
|
||||
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0))
|
||||
// A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar.
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(ctx.content_rect().center() + vec2(0.0, -40.0))
|
||||
.open(&mut win_open);
|
||||
let window = if d.name == "insertPicture" { window.default_height(240.0) } else { window };
|
||||
let window = match d.name.as_str() {
|
||||
"insertPicture" => window.default_height(240.0),
|
||||
"advancedFilter" => window.default_height(0.0),
|
||||
_ => window,
|
||||
};
|
||||
window.show(ctx, |ui| {
|
||||
ui.set_min_width(width - 20.0);
|
||||
match d.name.as_str() {
|
||||
"formatCells" => format_cells(app, ui, &mut d, &mut confirm),
|
||||
"dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open),
|
||||
"advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open),
|
||||
"insertFunction" => insert_function(app, ui, &mut d, &mut confirm),
|
||||
"find" => find(app, ui, &mut d),
|
||||
"goTo" => go_to(app, ui, &mut d, &mut confirm),
|
||||
"zoom" => zoom(ui, &mut d, &mut confirm, &mut open),
|
||||
"sort" => sort(app, ui, &mut d, &mut confirm),
|
||||
"nameManager" => name_manager(app, ui, &mut d, &mut open),
|
||||
"manageRules" => manage_rules(app, ui),
|
||||
@@ -1070,6 +1169,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
|
||||
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
|
||||
"pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"solver" => solver(ui, &mut d, &mut confirm, &mut open),
|
||||
"solverResults" => solver_results(app, ui, &mut d, &mut open),
|
||||
@@ -1320,6 +1420,79 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste Special as Excel lays it out: Paste and Operation each in two columns of options, Skip
|
||||
/// blanks and Transpose side by side, and Paste Link as a button that pastes at once. The options
|
||||
/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same.
|
||||
fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let lang = app.ui.language;
|
||||
let t = theme::Tokens::get(ui.ctx());
|
||||
let choices: Vec<(&'static str, &'static str, Vec<(&'static str, &'static str)>)> = d
|
||||
.fields
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
Field::Choice { key, label, options } => Some((*key, *label, options.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (key, label, options) in choices {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(lang.tr(label)).strong());
|
||||
egui::Frame::new().stroke(Stroke::new(1.0, t.separator)).corner_radius(4.0).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
// Column by column, as Excel fills them.
|
||||
let rows = options.len().div_ceil(2);
|
||||
egui::Grid::new(("paste_special", key)).num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| {
|
||||
for r in 0..rows {
|
||||
for c in 0..2 {
|
||||
if let Some((value, text)) = options.get(c * rows + r) {
|
||||
if ui.radio(cur == *value, lang.tr(text)).clicked() {
|
||||
d.set_value(key, json!(value));
|
||||
}
|
||||
} else {
|
||||
ui.label("");
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// In the same columns as the options above.
|
||||
egui::Frame::new().inner_margin(egui::Margin::symmetric(11, 0)).show(ui, |ui| {
|
||||
egui::Grid::new("paste_special_checks").num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| {
|
||||
for (key, label) in [("skipBlanks", "Skip blanks"), ("transpose", "Transpose (E)")] {
|
||||
let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false);
|
||||
if ui.checkbox(&mut b, lang.tr(label)).changed() {
|
||||
d.values.insert(key.into(), json!(b));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
// Excel's Paste Link pastes references to the copied cells straight away.
|
||||
if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() {
|
||||
d.values.insert("what".into(), json!("all"));
|
||||
d.values.insert("operation".into(), json!("none"));
|
||||
d.values.insert("link".into(), json!(true));
|
||||
*confirm = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)).fill(t.accent));
|
||||
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||
*confirm = true;
|
||||
}
|
||||
if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() {
|
||||
*open = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Paste Special's legacy mnemonics select options; Enter still performs the paste.
|
||||
fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
|
||||
if ctx.text_edit_focused() {
|
||||
@@ -1962,6 +2135,43 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Zoom's Magnification presets, as `preset` values and labels, in Excel's order.
|
||||
const ZOOM_PRESETS: [(&str, &str); 5] = [("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")];
|
||||
|
||||
/// The `preset` that shows a zoom percentage: its Magnification entry, or Custom.
|
||||
fn zoom_preset(percent: f64) -> &'static str {
|
||||
ZOOM_PRESETS.iter().map(|(val, _)| *val).find(|val| val.parse::<f64>().ok() == Some(percent)).unwrap_or("custom")
|
||||
}
|
||||
|
||||
/// View › Zoom, laid out as Excel's: one Magnification column of 200% to 25%, Fit selection and
|
||||
/// Custom with its percentage box. The current zoom is pre-selected; typing a percentage
|
||||
/// selects Custom; Fit selection zooms so the selected cells fill the grid.
|
||||
fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let cur = d.values.get("preset").and_then(Json::as_str).unwrap_or("custom").to_string();
|
||||
ui.label(egui::RichText::new(lang.tr("Magnification")).strong());
|
||||
for (val, text) in ZOOM_PRESETS {
|
||||
if ui.radio(cur == val, text).clicked() {
|
||||
d.set_value("preset", json!(val));
|
||||
}
|
||||
}
|
||||
if ui.radio(cur == "fit", lang.tr("Fit selection")).clicked() {
|
||||
d.set_value("preset", json!("fit"));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.radio(cur == "custom", format!("{}:", lang.tr("Custom"))).clicked() {
|
||||
d.set_value("preset", json!("custom"));
|
||||
}
|
||||
// Whole percentages, as Excel shows them; the engine clamps to 10..400 as well.
|
||||
let mut percent = d.values.get("percent").and_then(Json::as_f64).unwrap_or(100.0).round() as i64;
|
||||
if ui.add(egui::DragValue::new(&mut percent).range(10..=400).speed(1.0)).changed() {
|
||||
d.set_value("percent", json!(percent));
|
||||
}
|
||||
ui.label("%");
|
||||
});
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
let names: Vec<String> = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default();
|
||||
for n in names {
|
||||
@@ -1995,12 +2205,43 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
|
||||
ui.checkbox(&mut header, "My list has headers");
|
||||
d.values.insert("header".into(), json!(header));
|
||||
let mut levels: Vec<Json> = d.values.get("levels").and_then(Json::as_array).cloned().unwrap_or_default();
|
||||
// Columns of the region around the active cell, labelled with their header (or first value).
|
||||
let columns: Vec<(String, String)> = app
|
||||
.session
|
||||
.active()
|
||||
.and_then(|d| {
|
||||
let sh = d.wb.active()?;
|
||||
let region = gridcraft_engine::selection::current_region(sh, d.selection.active);
|
||||
Some(
|
||||
(region.start.col..=region.end.col)
|
||||
.map(|c| {
|
||||
let letter = gridcraft_engine::core::col_to_letters(c);
|
||||
// The region starts at the header row when one is present, so the
|
||||
// label always comes from start.row: header text with headers on,
|
||||
// first data value with them off.
|
||||
let label_row = region.start.row;
|
||||
let text = gridcraft_engine::display::cell_text(&d.wb, sh, gridcraft_engine::core::CellRef::new(label_row, c));
|
||||
let text: String = text.chars().take(24).collect();
|
||||
(letter.clone(), if text.is_empty() { letter } else { format!("{letter}: {text}") })
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut remove = None;
|
||||
for (i, lv) in levels.iter_mut().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(if i == 0 { "Sort by" } else { "Then by" });
|
||||
let mut col = lv["column"].as_str().unwrap_or("A").to_string();
|
||||
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0));
|
||||
if columns.is_empty() {
|
||||
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0));
|
||||
} else {
|
||||
egui::ComboBox::from_id_salt(("sort_col", i)).selected_text(col.clone()).show_ui(ui, |ui| {
|
||||
for (letter, label) in &columns {
|
||||
ui.selectable_value(&mut col, letter.clone(), label.as_str());
|
||||
}
|
||||
});
|
||||
}
|
||||
lv["column"] = json!(col.to_ascii_uppercase());
|
||||
let mut by = lv["by"].as_str().unwrap_or("values").to_string();
|
||||
egui::ComboBox::from_id_salt(("by", i))
|
||||
@@ -2030,7 +2271,15 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
|
||||
levels.remove(i);
|
||||
}
|
||||
if ui.button("+ Add Level").clicked() {
|
||||
levels.push(json!({"column": "B", "order": "asc"}));
|
||||
let used: Vec<String> = levels.iter().filter_map(|l| l.get("column").and_then(Json::as_str).map(str::to_string)).collect();
|
||||
let next = columns
|
||||
.iter()
|
||||
.map(|(l, _)| l)
|
||||
.find(|l| !used.contains(l))
|
||||
.cloned()
|
||||
.or_else(|| columns.last().map(|(l, _)| l.clone()))
|
||||
.unwrap_or_else(|| "B".into());
|
||||
levels.push(json!({"column": next, "order": "asc"}));
|
||||
}
|
||||
d.values.insert("levels".into(), Json::Array(levels.clone()));
|
||||
let mut open = true;
|
||||
@@ -2831,6 +3080,31 @@ mod data_validation_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_prefills_the_selection_and_submits_the_edited_range() {
|
||||
let mut session = gridcraft_engine::Session::default();
|
||||
session.new_workbook();
|
||||
session.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let mut dialog = Dialog::open(&mut app, "removeDuplicates", json!({})).unwrap();
|
||||
assert_eq!(dialog.values.get("range"), Some(&json!("B2:C5")));
|
||||
assert!(dialog.fields.iter().any(|field| matches!(field, Field::Text { key: "range", .. })));
|
||||
assert_eq!(build_params(&dialog)["range"], "B2:C5");
|
||||
dialog.set_value("range", json!("D2:E6"));
|
||||
assert_eq!(build_params(&dialog)["range"], "D2:E6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_with_only_the_cursor_prefills_the_current_region() {
|
||||
let mut session = gridcraft_engine::Session::default();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "B2:C4", "values": [["a", 1], ["a", 1], ["b", 2]]})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "C3"})).unwrap();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
let dialog = Dialog::open(&mut app, "removeDuplicates", json!({})).unwrap();
|
||||
assert_eq!(dialog.values.get("range"), Some(&json!("B2:C4")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_validation_opens_on_the_rule_of_the_active_cell() {
|
||||
let mut session = gridcraft_engine::Session::default();
|
||||
|
||||
@@ -189,6 +189,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Gridlines" => "Mřížka",
|
||||
"Headings" => "Záhlaví",
|
||||
"Zoom" => "Lupa",
|
||||
"Magnification" => "Měřítko",
|
||||
"Fit selection" => "Přizpůsobit výběru",
|
||||
"Forecast\nSheet" => "List\nprognózy",
|
||||
"Add footer" => "Přidat zápatí",
|
||||
"Add header" => "Přidat záhlaví",
|
||||
|
||||
@@ -193,6 +193,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Gridlines" => "Gitternetzlinien",
|
||||
"Headings" => "Überschriften",
|
||||
"Zoom" => "Zoom",
|
||||
"Magnification" => "Vergrößerung",
|
||||
"Fit selection" => "An Markierung anpassen",
|
||||
"Forecast\nSheet" => "Prognose-\nblatt",
|
||||
"Add footer" => "Fußzeile hinzufügen",
|
||||
"Add header" => "Kopfzeile hinzufügen",
|
||||
|
||||
@@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Gridlines" => "Líneas de cuadrícula",
|
||||
"Headings" => "Encabezados",
|
||||
"Zoom" => "Zoom",
|
||||
"Magnification" => "Ampliación",
|
||||
"Fit selection" => "Ajustar la selección",
|
||||
"Forecast\nSheet" => "Hoja de\npronóstico",
|
||||
"Add footer" => "Agregar pie de página",
|
||||
"Add header" => "Agregar encabezado",
|
||||
|
||||
@@ -213,6 +213,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Gridlines" => "Quadrillage",
|
||||
"Headings" => "En-têtes",
|
||||
"Zoom" => "Zoom",
|
||||
"Magnification" => "Zoom",
|
||||
"Fit selection" => "Ajuster la sélection",
|
||||
"100%" => "100 %",
|
||||
"Zoom to\nSelection" => "Zoom sur la\nsélection",
|
||||
"Freeze\nPanes" => "Figer les\nvolets",
|
||||
|
||||
@@ -212,6 +212,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Gridlines", "目盛線"),
|
||||
("Headings", "見出し"),
|
||||
("Zoom", "ズーム"),
|
||||
("Fit selection", "選択範囲に合わせる"),
|
||||
("100%", "100%"),
|
||||
("Zoom to\nSelection", "選択範囲を拡大"),
|
||||
("Freeze\nPanes", "ウィンドウ枠の固定"),
|
||||
|
||||
@@ -210,6 +210,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Gridlines" => "눈금선",
|
||||
"Headings" => "머리글",
|
||||
"Zoom" => "확대/축소",
|
||||
"Magnification" => "배율",
|
||||
"Fit selection" => "선택 영역에 맞춤",
|
||||
"100%" => "100%",
|
||||
"Zoom to\nSelection" => "선택 영역에\n맞춤",
|
||||
"Freeze\nPanes" => "틀\n고정",
|
||||
|
||||
@@ -207,6 +207,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Gridlines" => "Linhas de Grade",
|
||||
"Headings" => "Títulos",
|
||||
"Zoom" => "Zoom",
|
||||
"Fit selection" => "Ajustar seleção",
|
||||
"100%" => "100%",
|
||||
"Zoom to\nSelection" => "Zoom na\nSeleção",
|
||||
"Freeze\nPanes" => "Congelar\nPainéis",
|
||||
@@ -426,6 +427,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Number Stored as Text" => "Número Armazenado como Texto",
|
||||
"Convert to Number" => "Converter em Número",
|
||||
"Error checking options" => "Opções de verificação de erros",
|
||||
"Paste formulas that refer to the copied cells" => "Colar fórmulas que se referem às células copiadas",
|
||||
"Row Height" => "Altura da Linha",
|
||||
"Column Width" => "Largura da Coluna",
|
||||
"Standard Width" => "Largura Padrão",
|
||||
|
||||
@@ -210,6 +210,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Gridlines" => "Сетка",
|
||||
"Headings" => "Заголовки",
|
||||
"Zoom" => "Масштаб",
|
||||
"Magnification" => "Масштаб",
|
||||
"Fit selection" => "По выделению",
|
||||
"100%" => "100%",
|
||||
"Zoom to\nSelection" => "Масштаб по\nвыделению",
|
||||
"Freeze\nPanes" => "Закрепить\nобласти",
|
||||
|
||||
@@ -205,6 +205,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Gridlines" => "Linije mreže",
|
||||
"Headings" => "Zaglavlja",
|
||||
"Zoom" => "Uvećanje",
|
||||
"Magnification" => "Uvećanje",
|
||||
"Fit selection" => "Uklopi izbor",
|
||||
"100%" => "100%",
|
||||
"Zoom to\nSelection" => "Uvećaj na\nselekciju",
|
||||
"Freeze\nPanes" => "Zamrzni\nokna",
|
||||
|
||||
@@ -210,6 +210,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Gridlines" => "网格线",
|
||||
"Headings" => "标题",
|
||||
"Zoom" => "缩放",
|
||||
"Magnification" => "缩放比例",
|
||||
"Fit selection" => "恰好容纳选定区域",
|
||||
"100%" => "100%",
|
||||
"Zoom to\nSelection" => "缩放到\n所选内容",
|
||||
"Freeze\nPanes" => "冻结\n窗格",
|
||||
|
||||
@@ -292,7 +292,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
|
||||
("APR", Entry::Finish(Action::Pending("data.recentSources"))), // pending; Excel: Recent Sources; gap #12; reserved until the command lands
|
||||
("APS", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Show Queries
|
||||
("APT", Entry::Finish(Action::Pending("data.fromTable"))), // pending; Excel: From Table (query); gap #12; reserved until the command lands
|
||||
("AQ", Entry::Finish(Action::Command("data.advancedFilter"))), // Excel: Advanced Filter
|
||||
("AQ", Entry::Finish(Action::Dialog("advancedFilter"))), // Excel: Advanced Filter
|
||||
("AR", Entry::Continue), // group: group: Queries & Connections
|
||||
("ARA", Entry::Finish(Action::Command("data.refreshAll"))), // Excel: Refresh All
|
||||
("ARC", Entry::Finish(Action::Pending("data.cancelRefresh"))), // pending; Excel: Cancel Refresh; gap #12; reserved until the command lands
|
||||
|
||||
@@ -1972,8 +1972,8 @@
|
||||
},
|
||||
{
|
||||
"seq": "AQ",
|
||||
"kind": "command",
|
||||
"target": "data.advancedFilter",
|
||||
"kind": "dialog",
|
||||
"target": "advancedFilter",
|
||||
"excel": "Advanced Filter",
|
||||
"note": ""
|
||||
},
|
||||
@@ -2530,4 +2530,4 @@
|
||||
"note": "Alt+Q search box does not exist yet (issue #49)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2002,6 +2002,11 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if __k.clicked() {
|
||||
act(app, "data.reapply", json!({}));
|
||||
}
|
||||
let __k = small_button(ui, Icon::Filter, "Advanced", "Advanced Filter", false);
|
||||
control_keytip(app, ui, &__k, "dialog:advancedFilter");
|
||||
if __k.clicked() {
|
||||
app.open_dialog("advancedFilter", json!({}));
|
||||
}
|
||||
});
|
||||
sep(ui);
|
||||
let __k = big_button(ui, Icon::TextColumns, "Text to\nColumns", "Text to Columns", false);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Advanced Filter's dialog must route both actions to the same engine command.
|
||||
|
||||
use egui::vec2;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::control::{Outcome, handle};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
fn control(h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, params: Json) -> Json {
|
||||
let ctx = h.ctx.clone();
|
||||
match handle(h.state_mut(), &ctx, method, ¶ms) {
|
||||
Outcome::Done(v) => v,
|
||||
Outcome::Screenshot { .. } => json!(null),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_offers_copy_action_and_runs_unique_extract() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap();
|
||||
h.state_mut().open_dialog("advancedFilter", json!({}));
|
||||
h.run_steps(6);
|
||||
h.get_by_label("Copy matching rows elsewhere").click();
|
||||
h.run_steps(6);
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog").with("advancedFilter"))).unwrap();
|
||||
assert!(rect.width() < 500.0 && rect.height() < 400.0, "dialog must fit its controls: {rect:?}");
|
||||
assert!(
|
||||
h.state()
|
||||
.dialog
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.fields.iter().any(|f| matches!(f, gridcraft_ui_egui::dialogs::Field::Text { key: "copyTo", .. })))
|
||||
);
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().values["range"], "A1:A4");
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "copyTo", "value": "C1"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "unique", "value": true}));
|
||||
h.run_steps(6);
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(6);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert!(h.state().message.is_none());
|
||||
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(gridcraft_engine::core::CellRef::parse("C3").unwrap()).display(), "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_place_action_does_not_use_stale_copy_destination() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["B"]]})).unwrap();
|
||||
h.state_mut().run("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap();
|
||||
control(&mut h, "ui.dialog", json!({"name": "advancedFilter"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "range", "value": "A1:A3"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "criteria", "value": "H1:H2"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "copyTo", "value": "K1"}));
|
||||
let r = control(&mut h, "ui.dialog.confirm", json!({}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.state().session.active().unwrap().wb.active().unwrap().is_row_hidden(1));
|
||||
assert!(h.state().session.active().unwrap().wb.active().unwrap().value(gridcraft_engine::core::CellRef::parse("K1").unwrap()).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! `ui.dialog.confirm` runs a dialog's command with the parameters its OK button sends (#1459).
|
||||
|
||||
use egui::vec2;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::model::CfRule;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::control::{Outcome, handle};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
fn control(h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, params: Json) -> Json {
|
||||
let ctx = h.ctx.clone();
|
||||
match handle(h.state_mut(), &ctx, method, ¶ms) {
|
||||
Outcome::Done(v) => v,
|
||||
Outcome::Screenshot { .. } => json!(null),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirming_a_highlight_cells_rule_adds_the_rule_like_ok() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("range.setValues", json!({"range": "A1", "values": [[1], [3]]})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"range": "A1:A2"})).unwrap();
|
||||
// Home › Conditional Formatting › Highlight Cells Rules › Greater Than…
|
||||
control(&mut h, "ui.dialog", json!({"name": "cfQuick", "type": "cellIs", "operator": "greaterThan", "title": "Greater Than"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "value", "value": "2"}));
|
||||
let r = control(&mut h, "ui.dialog.confirm", json!({}));
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
assert!(h.state().dialog.is_none(), "the dialog closed");
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sheet.cond_formats.len(), 1, "{:?}", sheet.cond_formats);
|
||||
let rule = &sheet.cond_formats[0].rule;
|
||||
assert!(matches!(rule, CfRule::CellIs { a, .. } if a == "2"), "{rule:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirming_text_to_columns_splits_on_the_ticked_delimiter() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("range.setValues", json!({"range": "A1", "values": [["a;b"], ["c;d"]]})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"range": "A1:A2"})).unwrap();
|
||||
control(&mut h, "ui.dialog", json!({"name": "textToColumns"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "semicolon", "value": true}));
|
||||
let r = control(&mut h, "ui.dialog.confirm", json!({}));
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
let read = h.state_mut().run("sheet.read", json!({"range": "A1:B2"})).unwrap();
|
||||
assert_eq!(read["values"], json!([["a", "b"], ["c", "d"]]), "{read}");
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Dialogs can be dragged by their title bar, like Excel's, and stay where they are dropped.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
fn dialog_rect(h: &Harness, name: &str) -> egui::Rect {
|
||||
h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog").with(name))).unwrap()
|
||||
}
|
||||
|
||||
fn button(h: &mut Harness, pos: egui::Pos2, pressed: bool) {
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn drag_title_bar(h: &mut Harness, name: &str, by: egui::Vec2) {
|
||||
let start = dialog_rect(h, name).center_top() + vec2(0.0, 10.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(start));
|
||||
h.run_steps(2);
|
||||
button(h, start, true);
|
||||
for step in 1..=4 {
|
||||
h.input_mut().events.push(Event::PointerMoved(start + by * (step as f32 / 4.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
button(h, start + by, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_find_and_replace_title_bar_moves_the_dialog_and_it_stays_there() {
|
||||
let mut h = harness();
|
||||
h.state_mut().open_dialog("find", json!({}));
|
||||
h.run_steps(6);
|
||||
let before = dialog_rect(&h, "find");
|
||||
drag_title_bar(&mut h, "find", vec2(120.0, 80.0));
|
||||
h.run_steps(10);
|
||||
let after = dialog_rect(&h, "find");
|
||||
assert_eq!(after.min - before.min, vec2(120.0, 80.0), "dialog went from {before:?} to {after:?}");
|
||||
assert!(h.state().dialog.is_some(), "the drag does not close the dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_format_cells_and_sort_can_be_dragged_too() {
|
||||
for name in ["goTo", "formatCells", "sort"] {
|
||||
let mut h = harness();
|
||||
h.state_mut().open_dialog(name, json!({}));
|
||||
h.run_steps(6);
|
||||
let before = dialog_rect(&h, name);
|
||||
drag_title_bar(&mut h, name, vec2(-100.0, 60.0));
|
||||
h.run_steps(10);
|
||||
let after = dialog_rect(&h, name);
|
||||
assert_eq!(after.min - before.min, vec2(-100.0, 60.0), "{name} went from {before:?} to {after:?}");
|
||||
}
|
||||
}
|
||||
@@ -985,3 +985,21 @@ fn undo_after_backspace_restores_the_cleared_cell() {
|
||||
assert_eq!(value(&h, "A1"), Value::Number(3200.0));
|
||||
assert_eq!(value(&h, "C5"), Value::Number(11750.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_footer_dialog_opens_on_the_saved_header_and_footer() {
|
||||
// #523: the dialog started from a fixed empty header and `&CPage &P of &N`, so OK replaced the sheet's text.
|
||||
let mut s = blank();
|
||||
s.execute("pageLayout.headerFooter", json!({"header": "&LLeft", "footer": "&CConfidential"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
h.state_mut().open_dialog("headerFooter", json!({}));
|
||||
h.run_steps(2);
|
||||
let d = h.state_mut().dialog.as_mut().unwrap();
|
||||
assert_eq!(d.values["header"], json!("&LLeft"));
|
||||
assert_eq!(d.values["footer"], json!("&CConfidential"));
|
||||
d.values.insert("header".into(), json!("&CNew"));
|
||||
let vals = json!(d.values.clone());
|
||||
h.state_mut().run("pageLayout.headerFooter", vals).unwrap();
|
||||
let pr = &h.state().session.active().unwrap().wb.active().unwrap().print;
|
||||
assert_eq!((pr.header.as_str(), pr.footer.as_str()), ("&CNew", "&CConfidential"));
|
||||
}
|
||||
|
||||
@@ -2157,7 +2157,7 @@ fn keytip_apt_() {
|
||||
fn keytip_aq_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "AQ", false);
|
||||
assert!(h.state().message.is_some() || h.state().toast.is_some(), "AQ must invoke data.advancedFilter and surface its missing-params error");
|
||||
assert!(h.state().dialog.is_some(), "AQ must open the advancedFilter dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Paste Special: options in two columns, translated, OK pastes with them and Paste Link links.
|
||||
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[2], [3]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "C1", "values": [[10], [10]]})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "C1"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.state_mut().open_dialog("pasteSpecial", json!({}));
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn cell(h: &mut Harness, a1: &str) -> serde_json::Value {
|
||||
h.state_mut().session.execute("cell.get", json!({"cell": a1})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_operation_chosen_in_the_dialog_is_applied() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Values (V)").click();
|
||||
h.get_by_label("Multiply").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("OK").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert_eq!(cell(&mut h, "C1")["value"], json!(20.0));
|
||||
assert_eq!(cell(&mut h, "C2")["value"], json!(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_link_pastes_references_at_once() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Paste Link").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let c1 = cell(&mut h, "C1");
|
||||
assert_eq!(c1["value"], json!(2.0));
|
||||
assert!(c1["formula"].as_str().is_some_and(|f| f.contains("A1")), "{c1}");
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! #1906: the Sort dialog seeds "Sort by" from the active column instead of a
|
||||
//! free-text box stuck on "A", and the column list offers the region's columns.
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// Header row + three rows; the user's cursor sits in column B (Revenue).
|
||||
fn sales() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Month", "Revenue"], ["Mar", 30], ["Jan", 10], ["Feb", 20]]})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn col(h: &Harness) -> String {
|
||||
h.state().dialog.as_ref().unwrap().values["levels"][0]["column"].as_str().unwrap_or("<missing>").to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_dialog_seeds_the_active_column() {
|
||||
let mut h = harness(sales());
|
||||
h.state_mut().open_dialog("sort", json!({}));
|
||||
h.run_steps(2);
|
||||
assert_eq!(col(&h), "B", "Sort by starts on the column the cursor is in, not A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_level_picks_a_column_no_level_uses_yet() {
|
||||
let mut h = harness(sales());
|
||||
h.state_mut().open_dialog("sort", json!({}));
|
||||
h.run_steps(2);
|
||||
// Level 1 seeded B; clicking "+ Add Level" must not default level 2 to B too (#1906 review).
|
||||
h.run_steps(6);
|
||||
h.get_by_label("+ Add Level").click();
|
||||
h.run_steps(6);
|
||||
let cols: Vec<String> = h.state().dialog.as_ref().unwrap().values["levels"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|l| l["column"].as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
assert_eq!(cols.len(), 2, "two levels after Add Level");
|
||||
assert_ne!(cols[0], cols[1], "the second level defaults to a different column: {cols:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_by_the_seeded_column_orders_the_rows() {
|
||||
let mut h = harness(sales());
|
||||
h.state_mut().open_dialog("sort", json!({}));
|
||||
h.run_steps(2);
|
||||
// Accept the dialog: rows must come back ordered by Revenue (10, 20, 30).
|
||||
h.input_mut().events.push(egui::Event::Key {
|
||||
key: Key::Enter,
|
||||
physical_key: Some(Key::Enter),
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(2);
|
||||
let d = h.state().session.active().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
let nums: Vec<i64> = (1..4).map(|r| sh.value(gridcraft_engine::core::CellRef::new(r, 1))).filter_map(|v| v.as_f64().map(|n| n as i64)).collect();
|
||||
assert_eq!(nums, vec![10, 20, 30], "sort ran on the seeded column B");
|
||||
}
|
||||
@@ -83,3 +83,65 @@ fn a_custom_percentage_after_a_magnification_wins_and_an_unedited_ok_keeps_the_z
|
||||
control(&mut h, "ui.dialog.confirm", json!({}));
|
||||
assert_eq!(zoom(&h), 225);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_current_zoom_is_preselected_as_its_magnification_or_as_custom() {
|
||||
let mut h = harness();
|
||||
h.state_mut().open_dialog("zoom", json!({}));
|
||||
h.run_steps(2);
|
||||
let d = h.state().dialog.clone().unwrap();
|
||||
assert_eq!(d.values["preset"], json!("100"));
|
||||
assert_eq!(d.values["percent"], json!(100));
|
||||
control(&mut h, "ui.dialog.cancel", json!({}));
|
||||
h.state_mut().run("view.zoom", json!({"percent": 130})).unwrap();
|
||||
h.state_mut().open_dialog("zoom", json!({}));
|
||||
h.run_steps(2);
|
||||
let d = h.state().dialog.clone().unwrap();
|
||||
assert_eq!(d.values["preset"], json!("custom"));
|
||||
assert_eq!(d.values["percent"], json!(130));
|
||||
// An unedited OK keeps the zoom.
|
||||
let r = control(&mut h, "ui.dialog.confirm", json!({}));
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
assert_eq!(zoom(&h), 130);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_percentage_selects_custom_and_sets_that_zoom() {
|
||||
let mut h = harness();
|
||||
control(&mut h, "ui.dialog", json!({"name": "zoom"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "preset", "value": "fit"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "percent", "value": 150}));
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().values["preset"], json!("custom"));
|
||||
let r = control(&mut h, "ui.dialog.confirm", json!({}));
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
assert_eq!(zoom(&h), 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_selection_zooms_so_the_selected_cells_fill_the_grid() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("selection.set", json!({"range": "A1:J40"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let view = h.state().grid.cells_rect.unwrap();
|
||||
h.state_mut().open_dialog("zoom", json!({}));
|
||||
h.run_steps(6);
|
||||
h.get_by_label("Fit selection").click();
|
||||
h.run_steps(6);
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(6);
|
||||
assert!(h.state().dialog.is_none());
|
||||
// The same answer the ribbon's Zoom to Selection gives for this grid.
|
||||
let mut expected = Session::new();
|
||||
expected.new_workbook();
|
||||
expected.execute("selection.set", json!({"range": "A1:J40"})).unwrap();
|
||||
let r = expected.execute("view.zoomToSelection", json!({"viewWidth": view.width(), "viewHeight": view.height()})).unwrap();
|
||||
assert_eq!(json!(zoom(&h)), r["zoom"]);
|
||||
assert!(zoom(&h) > 10 && zoom(&h) < 100, "{}", zoom(&h));
|
||||
// Fit selection over the control channel, before any frame is drawn, uses the grid size captured on opening.
|
||||
h.state_mut().run("selection.set", json!({"range": "A1"})).unwrap();
|
||||
control(&mut h, "ui.dialog", json!({"name": "zoom"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "preset", "value": "fit"}));
|
||||
let r = control(&mut h, "ui.dialog.confirm", json!({}));
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
assert_eq!(zoom(&h), 400);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user