Merge pull request #1493 from Hamza0101/fix/overset-export-warnings

An export says what text its pages had no room for (#1465)
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-11-02 23:24:29 -05:00
commit 312eefac9b
4 files changed
+396 -8

No files matched your search

+2
View File
@@ -110,6 +110,8 @@ pub(super) fn export_pptx(s: &mut Session, p: &Value) -> Result<Value> {
if rasterized > 0 {
r.warnings.push(format!("{rasterized} object(s) went in as images"));
}
// A slide has no room for the overset text either (#1465).
r.warnings.extend(crate::cmd::preflight::overset_warnings(s, opts.pages.as_deref()));
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
+236 -1
View File
@@ -87,8 +87,27 @@ fn first_page_ref(pdf: &[u8]) -> Option<usize> {
s[i..].split_whitespace().next()?.parse().ok()
}
/// Every page sharing a spread with one of `pages` (0-based), for an export that puts whole
/// spreads on a sheet. The pages come back in document order.
fn with_their_spreads(s: &Session, pages: &[usize]) -> Vec<usize> {
let Some(st) = s.active() else { return pages.to_vec() };
let d = &st.doc;
let spreads: std::collections::BTreeSet<usize> = pages.iter().filter_map(|p| d.page_loc(*p)).map(|(si, _)| si).collect();
if spreads.is_empty() {
return pages.to_vec();
}
(0..d.page_count()).filter(|p| d.page_loc(*p).is_some_and(|(si, _)| spreads.contains(&si))).collect()
}
pub(super) fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let r = pdf_export(s, p)?;
let mut r = pdf_export(s, p)?;
// Text the pages have no room for is not in the file: say so, as preflight does (#1465).
// A spread export puts the whole spread on the sheet, so choosing page 2 exports page 3 as
// well and page 3's overset is as absent from the file as page 2's would be. The selection
// is widened to the spreads it touches before the warnings are collected (noted in review).
let opts = options(p, s.doc()?.doc.page_count())?;
let warn_pages = opts.pages.as_deref().map(|pages| if opts.spreads { with_their_spreads(s, pages) } else { pages.to_vec() });
r.warnings.extend(crate::cmd::preflight::overset_warnings(s, warn_pages.as_deref()));
match str_param(p, "path") {
Some(path) => {
write_file(path, &r.bytes)?;
@@ -478,3 +497,219 @@ mod page_list_tests {
assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
}
}
#[cfg(test)]
mod overset_tests {
use serde_json::json;
use crate::Session;
/// A page-based export leaves out the text its frames have no room for, and said nothing
/// about it: an imported 58-page form lost whole sentences — a data-consent clause, a
/// statutory rule, the last row of two tables — with `warnings` empty while preflight
/// reported 19 overset stories (#1465).
#[test]
fn an_export_warns_about_the_text_the_pages_had_no_room_for() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap();
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 60], "content": "text", "text": text})).unwrap();
let (id, sid) = (frame["id"].as_u64().unwrap(), frame["story"].clone());
// What preflight says about the same frame, for the warning to agree with.
let issues = s.execute("preflight.run", &json!({})).unwrap();
let overset = issues["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "overset").expect("preflight reports it").clone();
assert_eq!(overset["message"], "Overset text: 76 characters");
for command in ["file.exportPdf", "file.exportPptx"] {
let r = s.execute(command, &json!({})).unwrap();
let warnings = r["warnings"].as_array().expect("warnings").clone();
let about: Vec<&str> = warnings.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).collect();
assert_eq!(about.len(), 1, "{command}: {warnings:?}");
let w = about[0];
assert!(w.contains("page 1"), "{command}: the page it is on: {w}");
assert!(w.contains("76 character"), "{command}: the count preflight gives: {w}");
assert!(w.contains(&format!("frame {id}")), "{command}: the frame: {w}");
}
// The pages being exported are the ones reported on: page 2 has no overset frame.
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
assert_eq!(r["warnings"], json!([]), "{r}");
// A reflowable export carries the whole story, so it has nothing to report.
let r = s.execute("file.exportHtml", &json!({})).unwrap();
assert_eq!(r["warnings"], json!([]), "{r}");
assert!(r["text"].as_str().unwrap().contains("must not vanish"), "the HTML has the text the PDF left out");
// `overset` is the index where the text stops fitting; `oversetChars` is how much is left,
// which is the number preflight reports. The two were read as the same thing.
let story = s.execute("story.get", &json!({"story": sid})).unwrap();
assert_eq!(story["overset"], json!(68), "{story}");
assert_eq!(story["oversetChars"], json!(76), "{story}");
// A frame the text fits: nothing is reported anywhere.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 180], "content": "text", "text": "Short."})).unwrap();
for command in ["file.exportPdf", "file.exportPptx"] {
assert_eq!(s.execute(command, &json!({})).unwrap()["warnings"], json!([]), "{command}");
}
let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap();
assert!(story["overset"].is_null() && story["oversetChars"].is_null(), "{story}");
}
/// A sheet draws its spread's items without asking which page each one sits on, so a frame
/// straddling two pages is drawn, in part, on both. Choosing the page by the frame's centre
/// alone meant a frame centred on a page the export leaves out had its warning dropped while
/// the part on an exported page went into the file (#1493 review).
#[test]
fn an_export_warns_about_a_frame_that_only_crosses_into_one_of_its_pages() {
let mut s = Session::new();
// Facing pages, so pages 2 and 3 are one spread and a frame can straddle their boundary.
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap();
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
// Spread 1 is pages 2-3; its page 2 runs x 0..300 and page 3 runs x 300..600. The frame
// is centred on page 3 and reaches back over the boundary into page 2.
let frame = json!({"spread": 1, "rect": [260, 20, 420, 60], "content": "text", "text": text});
let id = s.execute("frame.create", &frame).unwrap()["id"].as_u64().unwrap();
let about = |s: &mut Session, pages: serde_json::Value| {
let params = if pages.is_null() { json!({}) } else { json!({"pages": pages}) };
let r = s.execute("file.exportPdf", &params).unwrap();
let ws = r["warnings"].as_array().cloned().unwrap_or_default();
ws.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_owned).collect::<Vec<_>>()
};
// The whole document: reported once, at the page the frame is centred on.
let all = about(&mut s, serde_json::Value::Null);
assert_eq!(all.len(), 1, "{all:?}");
assert!(all[0].contains("page 3") && all[0].contains(&format!("frame {id}")), "{}", all[0]);
// Exporting page 3 alone: the centre page, reported as before.
let three = about(&mut s, json!([3]));
assert_eq!(three.len(), 1, "{three:?}");
assert!(three[0].contains("page 3"), "{}", three[0]);
// Exporting page 2 alone: the frame's centre is on page 3, which is not in the export --
// but part of the frame is drawn on page 2, so it is still reported, and the page named is
// one the export has.
let two = about(&mut s, json!([2]));
assert_eq!(two.len(), 1, "a frame crossing into page 2 is reported: {two:?}");
assert!(two[0].contains("page 2"), "and the page it names is an exported one: {}", two[0]);
assert!(two[0].contains(&format!("frame {id}")), "{}", two[0]);
// A page the frame does not reach at all: nothing to say.
assert_eq!(about(&mut s, json!([4])), Vec::<String>::new());
}
/// `overset` indexes `text` in UTF-8 bytes, not characters: a client that slices a JavaScript
/// string at it cuts the wrong place once the text is not ASCII (noted in review).
#[test]
fn overset_is_a_byte_offset_into_the_text() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap();
// Accented letters are two bytes each, so the byte offset runs ahead of the character index.
let text = "Réservé à l'évaluation détaillée des éléments.\nCette phrase dépasse le cadre et n'est pas dans le PDF exporté du tout.";
let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 40], "content": "text", "text": text})).unwrap();
let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap();
let at = story["overset"].as_u64().expect("it oversets") as usize;
assert!(text.is_char_boundary(at), "a byte offset that splits no character: {at}");
assert!(at > text[..at].chars().count(), "ahead of the character index: {at} bytes, {} characters", text[..at].chars().count());
// And `oversetChars` counts characters, which is why the two numbers differ.
assert_eq!(story["oversetChars"], json!(text[at..].chars().count()), "{story}");
}
/// A parent page's frame is composed once per page that shows it: its page number marker is
/// that page's, so the same frame fits on page 1 and oversets on page 10 -- and a parent page
/// no exported page uses puts nothing in the file to be missing. Composing it once with no
/// page reported neither: every selection got the same unpaged warning (noted in review).
#[test]
fn a_parent_frame_is_checked_on_the_pages_that_show_it() {
let about = |r: &serde_json::Value| -> Vec<String> {
r["warnings"]
.as_array()
.map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect())
.unwrap_or_default()
};
// A parent page that only page 1 uses.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap();
s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": null})).unwrap();
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
let frame = s
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 280, 60], "content": "text", "text": text}))
.unwrap();
let id = frame["id"].as_u64().unwrap();
let w = about(&s.execute("file.exportPdf", &json!({"pages": [1]})).unwrap());
assert_eq!(w.len(), 1, "page 1 shows the parent: {w:?}");
assert!(w[0].contains("page 1") && w[0].contains(&format!("frame {id}")) && w[0].contains("76 character"), "{}", w[0]);
// Page 2 has no parent, so nothing of that frame is in its export to be missing.
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
assert!(about(&r).is_empty(), "page 2 does not use the parent: {r}");
// Page-dependent text: the marker is one digit up to page 9 and two from page 10, and the
// wider line no longer fits. 112 pt is the width where that is the only difference.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 12, "facingPages": false, "margins": 0})).unwrap();
let text = format!("Continued on page {}", designcraft_doc::story::PAGE_NUMBER);
s.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 132, 36], "content": "text", "text": text}))
.unwrap();
for page in [1, 9] {
let r = s.execute("file.exportPdf", &json!({"pages": [page]})).unwrap();
assert!(about(&r).is_empty(), "page {page} fits one digit: {r}");
}
let w = about(&s.execute("file.exportPdf", &json!({"pages": [10]})).unwrap());
assert_eq!(w.len(), 1, "two digits do not fit: {w:?}");
assert!(w[0].contains("page 10"), "{}", w[0]);
// The whole document: one line naming every page it happens on, not one line per page.
let w = about(&s.execute("file.exportPdf", &json!({})).unwrap());
assert_eq!(w.len(), 1, "one line for the story: {w:?}");
assert!(w[0].starts_with("pages 10, 11, 12:"), "{}", w[0]);
}
/// A spread export puts the whole spread on the sheet, so choosing one page of it exports its
/// partner too -- and that partner's overset is just as absent from the file. Reporting only
/// the pages that were named left it unmentioned (noted in review of #1465).
#[test]
fn a_spread_export_warns_about_the_pages_it_takes_with_it() {
let mut s = Session::new();
// Facing pages: 2 and 3 share a spread, 1 is the cover on its own.
s.execute("file.new", &json!({"width": 200, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap();
let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning.";
// A frame on page 3, whose text does not fit it. `frame.create` takes a spread and
// spread coordinates, so page 3's own bounds say where to put it.
let (si, b) = {
let d = &s.active().expect("a document").doc;
let (si, pi) = d.page_loc(2).expect("page 3 is there");
(si, d.spreads[si].pages[pi].bounds())
};
let rect = json!([b.x0 + 20.0, b.y0 + 20.0, b.x0 + 180.0, b.y0 + 60.0]);
let frame = s.execute("frame.create", &json!({"spread": si, "rect": rect, "content": "text", "text": text})).unwrap();
let id = frame["id"].as_u64().unwrap();
let about = |r: &serde_json::Value| -> Vec<String> {
r["warnings"]
.as_array()
.map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect())
.unwrap_or_default()
};
// Page by page, choosing page 2 reports nothing: page 2 holds no overset frame.
let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap();
assert!(about(&r).is_empty(), "page 2 alone has nothing to report: {r}");
// As spreads, choosing page 2 exports page 3 with it, so page 3's overset is reported.
let r = s.execute("file.exportPdf", &json!({"pages": [2], "spreads": true})).unwrap();
let w = about(&r);
assert_eq!(w.len(), 1, "the page that came along is reported too: {r}");
assert!(w[0].contains("page 3") && w[0].contains(&format!("frame {id}")), "{}", w[0]);
// And a page on another spread entirely is still not reported.
let r = s.execute("file.exportPdf", &json!({"pages": [1], "spreads": true})).unwrap();
assert!(about(&r).is_empty(), "the cover's spread holds no overset frame: {r}");
}
}
+152 -6
View File
@@ -38,6 +38,25 @@ fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option<usi
Some(d.first_page_of_spread(si) + sp.page_at_x(bounds.center().x).unwrap_or(0))
}
/// Every page of the spread the frame reaches, not just the one its centre is on.
///
/// A sheet draws the items of its spread without testing which page they sit on, so a frame that
/// straddles two pages is drawn, in part, on both. Choosing by the centre alone dropped the
/// warning for a frame whose centre is on a page the export leaves out while the part on an
/// exported page went into the file (noted in review). Empty when the frame is on no document
/// spread, which is the same answer `page_of` gives.
fn pages_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Vec<usize> {
let SpreadRef::Doc(si) = sr else { return vec![] };
let Some(sp) = d.spreads.get(si) else { return vec![] };
let bounds = parent.transform_rect_bbox(it.bounds());
let first = d.first_page_of_spread(si);
let touched: Vec<usize> =
sp.pages.iter().enumerate().filter(|(_, pg)| bounds.x1 > pg.x && bounds.x0 < pg.x + pg.width).map(|(i, _)| first + i).collect();
// A frame wholly on the pasteboard touches no page's span; it still belongs to the page
// `page_at_x` nominates, so the two functions agree about where it is.
if touched.is_empty() { page_of(d, sr, it, parent).into_iter().collect() } else { touched }
}
/// Characters a missing-glyph report names before "and N more".
const MISSING_GLYPHS_LISTED: usize = 20;
@@ -74,11 +93,19 @@ fn missing_glyphs(
}
}
/// Locate an embedded frame at the outer page frame that actually renders it.
fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(designcraft_doc::ItemId, Option<usize>)> {
/// Locate an embedded frame at the outer page frame that actually renders it: the frame, the page
/// its centre is on, and every page it reaches (see [`pages_of`]).
type Located = (designcraft_doc::ItemId, Option<usize>, Vec<usize>);
fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<Located> {
let d = &s.active()?.doc;
let at = |id: designcraft_doc::ItemId, loc: &designcraft_doc::ItemLoc| {
let it = d.item(id);
let xf = d.parent_xf(loc);
(id, it.and_then(|it| page_of(d, loc.spread, it, xf)), it.map(|it| pages_of(d, loc.spread, it, xf)).unwrap_or_default())
};
if let Some(loc) = d.find(target) {
return Some((target, d.item(target).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc)))));
return Some(at(target, &loc));
}
fn contains(root: &designcraft_compose::FrameText, target: designcraft_doc::ItemId) -> bool {
let mut frames = vec![root];
@@ -107,12 +134,131 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des
let frame = cs.frames.iter().find(|f| contains(f, current))?;
current = frame.frame;
if let Some(loc) = d.find(current) {
return Some((current, d.item(current).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc)))));
return Some(at(current, &loc));
}
}
None
}
/// Text that is in the document but not in a page-based export (PDF, PowerPoint): the overset
/// part of a story, and a story with no frame at all. The export used to write the file and
/// report nothing, so whole sentences left an imported document without a hint anywhere (#1465).
///
/// One line per story, naming the page and the frame as the preflight issue does. `pages` are the
/// 0-based pages being exported; a story whose overset is on a page that is not being exported is
/// not reported, and one with no page of its own always is. A reflowable export (EPUB, HTML) or a
/// text export carries the whole story, so neither calls this.
/// Pages a single warning names before "and N more".
const MAX_WARNED_PAGES: usize = 10;
/// How the pages one warning covers are named: "page 3", "pages 3, 4", "pages 3, 4 and 6 more".
fn page_list(pages: &[usize]) -> String {
let shown: Vec<String> = pages.iter().take(MAX_WARNED_PAGES).map(|p| (p + 1).to_string()).collect();
let rest = pages.len() - shown.len();
let list = shown.join(", ");
match (pages.len(), rest) {
(1, _) => format!("page {list}"),
(_, 0) => format!("pages {list}"),
_ => format!("pages {list} and {rest} more"),
}
}
/// The parent spread and the page of it a frame sits on, or `None` for a frame on a document page.
fn parent_page_of(d: &Document, target: designcraft_doc::ItemId) -> Option<(usize, usize)> {
let loc = d.find(target)?;
let SpreadRef::Parent(pi) = loc.spread else { return None };
let bounds = d.parent_xf(&loc).transform_rect_bbox(d.item(target)?.bounds());
Some((pi, d.spread(loc.spread)?.page_at_x(bounds.center().x).unwrap_or(0)))
}
/// Document pages that draw parent page `pp` of parent spread `pi`, in document order.
fn pages_showing(d: &Document, (pi, pp): (usize, usize)) -> Vec<usize> {
(0..d.page_count())
.filter(|p| d.page_loc(*p).is_some_and(|(si, pgi)| d.shown_parent_pages(SpreadRef::Doc(si), pgi).contains(&(pi, pp))))
.collect()
}
/// Characters of `story` that `cs` had no room for.
fn overset_chars(story: &designcraft_doc::Story, cs: &ComposedStory) -> usize {
story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count()
}
pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec<String> {
let Some(st) = s.active() else { return vec![] };
let d = &st.doc;
let mut out = Vec::new();
for story in d.stories.values() {
let last = story.frames.last().copied();
// A story on a parent page is composed once per page that shows it: its page number
// markers and variables are that page's, so the same frame can fit on one page and
// overset on another, and a parent page no exported page uses puts nothing in the file
// to be missing. Composing it once with no page reported neither (noted in review).
if let Some(parent) = last.and_then(|f| parent_page_of(d, f)) {
let mut by_count: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
for page in pages_showing(d, parent) {
if pages.is_some_and(|sel| !sel.contains(&page)) {
continue;
}
let cs = s.cache.get(d, story.id, Some(page));
let n = overset_chars(story, &cs);
if cs.is_overset() && n > 0 {
by_count.entry(n).or_default().push(page);
}
}
let item = last.map_or(0, |f| f.0);
out.extend(
by_count.into_iter().map(|(n, ps)| {
format!("{}: {n} character(s) of the story in frame {item} do not fit it and are not in the export", page_list(&ps))
}),
);
continue;
}
let cs = s.cache.get(d, story.id, None);
if !cs.is_overset() {
continue;
}
let n = overset_chars(story, &cs);
if n == 0 {
continue;
}
if story.frames.is_empty() {
out.push(format!("a story with no text frame: {n} character(s) of it are not in the export"));
continue;
}
let location = last.and_then(|f| overset_location(s, f));
let centre = location.as_ref().and_then(|(_, page, _)| *page);
let touched: &[usize] = location.as_ref().map(|(_, _, ps)| ps.as_slice()).unwrap_or_default();
// A sheet draws its spread's items without asking which page they are on, so a frame
// straddling two pages is drawn in part on both. Testing the centre page alone dropped
// the warning for a frame centred on a page the export leaves out while the part on an
// exported page went into the file (noted in review).
//
// The page named is one the export has: the centre page when that is exported, so an
// ordinary frame reads as before, otherwise the first exported page the frame reaches.
let page = match pages {
None => centre,
Some(sel) => {
let exported: Vec<usize> = touched.iter().copied().filter(|p| sel.contains(p)).collect();
match centre.filter(|p| sel.contains(p)).or_else(|| exported.first().copied()) {
Some(p) => Some(p),
// Nothing of this frame is on an exported page.
None if centre.is_some() || !touched.is_empty() => continue,
None => None,
}
}
};
let item = location.as_ref().map(|(item, _, _)| *item).or(last);
out.push(match (page, item) {
(Some(page), Some(item)) => {
format!("page {}: {n} character(s) of the story in frame {} do not fit it and are not in the export", page + 1, item.0)
}
(None, Some(item)) => format!("the story in frame {}: {n} character(s) do not fit and are not in the export", item.0),
_ => format!("{n} character(s) of a story do not fit their frame and are not in the export"),
});
}
out
}
pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
let Some(st) = s.active() else { return vec![] };
let d = &st.doc;
@@ -138,8 +284,8 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
} else {
format!("Overset text: {n} characters")
},
item: location.map(|(item, _)| item.0).or(last.map(|i| i.0)),
page: location.and_then(|(_, page)| page),
item: location.as_ref().map(|(item, _, _)| item.0).or(last.map(|i| i.0)),
page: location.as_ref().and_then(|(_, page, _)| *page),
});
}
}
+6 -1
View File
@@ -191,12 +191,17 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| {
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, lines, overset (the UTF-8 byte offset into `text` where it stops fitting its frames, not a character index, null when it all fits) and oversetChars (how many characters that leaves, as preflight reports them)", has_doc, |s, p| {
let st = s.doc()?;
let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?;
let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let cs = s.cache.get(&st.doc, sid, None);
// `overset` is where the text stops fitting, not how much is left over: the two were
// read as the same number and disagreed with preflight's count (#1465).
v["overset"] = json!(cs.overset_at);
v["oversetChars"] = json!(cs
.overset_at
.map(|at| st.doc.stories.get(&sid).and_then(|story| story.text.get(at..)).unwrap_or("").chars().count()));
v["lines"] = json!(cs.line_count());
Ok(v)
}),