fix(mcp): discard malformed JSON replies before reusing connections

This commit is contained in:
euisuh
2026-10-16 02:02:48 +07:00
committed by bflatastic
parent 256b052981
commit e9fc3c57ae
6 changed files with 141 additions and 9 deletions
+10 -5
View File
@@ -114,11 +114,16 @@ impl Backend for Remote {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
let reply = self.roundtrip(&line).map_err(|e| {
self.conn = None;
format!("VectorCraft app at {}: `{method}` failed: {e}; operation may have completed; inspect state before retrying", self.addr)
})?;
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
let v: Value = self
.roundtrip(&line)
.and_then(|reply| {
serde_json::from_str(reply.trim())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("bad reply from app: {e}")))
})
.map_err(|e| {
self.conn = None;
format!("VectorCraft app at {}: `{method}` failed: {e}; operation may have completed; inspect state before retrying", self.addr)
})?;
if v.get("id").is_none() && v.get("ok").and_then(Value::as_bool) == Some(false) {
// The app's refusals carry no id (too many connections, a line it would not read):
// it ran nothing and closes the connection, so the next call starts a fresh one.
+116
View File
@@ -65,6 +65,62 @@ fn framing_basics() {
assert_eq!(v.as_array().unwrap().len(), 1);
}
#[test]
fn framing_rejects_invalid_versions_before_dispatch() {
use std::cell::Cell;
use std::rc::Rc;
struct CountingBackend(Rc<Cell<usize>>);
impl Backend for CountingBackend {
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
self.0.set(self.0.get() + 1);
Ok(json!({}))
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"test".into()
}
}
let calls = Rc::new(Cell::new(0));
let mut s = Server::new(Box::new(CountingBackend(calls.clone())));
let mut request = json!({"id": 7, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "edit.clear"}}});
for version in [None, Some(json!("1.0")), Some(json!("3.0")), Some(json!(2.0)), Some(Value::Null)] {
if let Some(version) = version {
request["jsonrpc"] = version;
}
let reply: Value = serde_json::from_str(&s.handle_line(&request.to_string()).unwrap()).unwrap();
assert_eq!(calls.get(), 0, "invalid envelope dispatched: {request}");
assert_eq!(reply["error"]["code"], -32600, "{reply}");
assert_eq!(reply["id"], 7);
}
request["jsonrpc"] = json!("2.0");
let reply = s.handle(request).unwrap();
assert_eq!(reply["result"]["isError"], false, "{reply}");
assert_eq!(calls.get(), 1);
}
#[test]
fn framing_rejects_invalid_notification_versions_in_batches() {
let mut s = server();
for version in [None, Some(json!("1.0")), Some(json!(2.0)), Some(Value::Null)] {
let mut notification = json!({"method": "notifications/initialized"});
if let Some(version) = version {
notification["jsonrpc"] = version;
}
let batch = json!([notification, {"jsonrpc": "2.0", "id": "ping", "method": "ping"}]);
let replies: Value = serde_json::from_str(&s.handle_line(&batch.to_string()).unwrap()).unwrap();
assert!(!s.is_initialized(), "invalid notification changed state: {batch}");
assert_eq!(replies.as_array().unwrap().len(), 1, "{replies}");
assert_eq!(replies[0]["id"], "ping");
assert_eq!(replies[0]["result"], json!({}));
}
assert_eq!(s.handle(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})), None);
assert!(s.is_initialized());
}
#[test]
fn serve_loop_writes_one_line_per_request() {
let mut s = server();
@@ -506,6 +562,66 @@ fn remote_does_not_replay_when_edit_reply_is_lost() {
assert!(next.is_ok(), "the next call must reconnect: {next:?}");
}
#[test]
fn remote_rejects_uncorrelated_replies_without_replaying() {
for reply in [
json!({"ok": true, "result": {}}).to_string(),
json!({"id": null, "ok": true, "result": {}}).to_string(),
json!({"id": "1", "ok": true, "result": {}}).to_string(),
json!({"id": 2, "ok": true, "result": {}}).to_string(),
json!({"id": 2, "ok": false, "error": "unrelated error"}).to_string(),
"not json".into(),
] {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap().to_string();
let h = std::thread::spawn(move || {
let timeout = std::time::Duration::from_secs(5);
let mut seen = vec![];
for connection in 0..2 {
let deadline = std::time::Instant::now() + timeout;
let stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => std::thread::yield_now(),
Err(e) => panic!("accept failed: {e}"),
}
};
stream.set_nonblocking(false).unwrap();
stream.set_read_timeout(Some(timeout)).unwrap();
stream.set_write_timeout(Some(timeout)).unwrap();
let mut out = stream.try_clone().unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let msg: Value = serde_json::from_str(&line).unwrap();
seen.push(msg.clone());
if connection == 0 {
writeln!(out, "{reply}").unwrap();
line.clear();
assert_eq!(reader.read_line(&mut line).unwrap(), 0, "invalid reply must close the connection without another request");
} else {
writeln!(out, "{}", json!({"id": msg["id"], "ok": true, "result": {"objects": 1}})).unwrap();
}
}
seen
});
let mut remote = Remote::connect(&addr).unwrap();
let first = remote.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 20, "height": 20}}));
let next = remote.call("document.inspect", json!({}));
drop(remote);
let seen = h.join().unwrap();
let error = first.expect_err("an uncorrelated reply cannot confirm the edit");
assert!(error.contains("operation may have completed"), "{error}");
assert_eq!(next.unwrap(), json!({"objects": 1}));
assert_eq!(seen.len(), 2);
assert_eq!(seen[0]["method"], "engine.execute");
assert_eq!(seen[1]["method"], "document.inspect", "the edit must not be replayed");
assert_eq!(seen[0]["id"], 1);
assert_eq!(seen[1]["id"], 2);
}
}
#[test]
fn remote_rejects_a_reply_with_a_mismatched_id_and_reconnects() {
// A fake control server that answers the first request with another call's id, then serves
+7
View File
@@ -174,5 +174,12 @@ mod tests {
assert!(!s.settled(&ctx, 2, 2, 10.25), "so does another document");
assert!(s.settled(&ctx, 2, 2, 1.0), "a clock that went back doesn't hold it up");
assert!(!s.settled(&ctx, 2, 2, f64::NAN), "an unknown elapsed time is still unsettled");
let mut s = Settle::default();
assert!(!s.settled(&ctx, 1, 1, 0.0));
for (now, expected) in [(f64::NEG_INFINITY, true), (-0.0, false), (0.0, false), (SETTLE_SECS, true), (f64::INFINITY, true), (f64::NAN, false)]
{
assert_eq!(s.settled(&ctx, 1, 1, now), expected, "clock boundary {now}");
}
}
}
+3 -2
View File
@@ -110,8 +110,9 @@ refused with `-32600` (requests) or ignored (notifications, which have no id to
to) before it is dispatched. When a tool call reaches a running app, it is one control-channel
request, and the app echoes its `id` in the reply: a reply with any other `id` is rejected, the
connection dropped, and the next call starts a fresh one — the call reports uncertainty like any
reply that never arrived whole. The app's refusals carry no `id` (a connection past its 16, a line it
would not read): the call reports the app's reason and says nothing was run. Before each call the
reply that never arrived whole. A malformed JSON reply also drops the connection and reports that
uncertainty; the failed operation is never replayed automatically. The app's refusals carry no `id`
(a connection past its 16, a line it would not read): the call reports the app's reason and says nothing was run. Before each call the
server checks that the app still holds the connection and opens a new one when it doesn't (the app
closes a connection left idle for 5 minutes, and a restarted app is a new one), so a request is
never written to a closed socket; when the app can't be reached, the call says that nothing was
+2 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -332,7 +332,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 65% | scripting surface, batch (Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 |
| Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 14 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…) | 13–20 |
| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
@@ -558,6 +558,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Control transport:** idle sockets expire after 5 minutes, partial lines after an absolute 30 seconds from their first byte, and reply writes after 60 seconds (#1726). Real TCP regressions cover stalled peers and slot recovery; command execution has its separate existing timeout. MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739).
- **Malformed MCP replies:** invalid JSON from the app drops the connection, reports that the operation may have completed and leaves the next call to reconnect; it never replays the edit (#1761). TCP regressions cover malformed and uncorrelated replies, connection closure and a subsequent inspection. Envelope regressions also verify zero backend calls and unchanged initialization state for invalid versions.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795).
- **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests_pdfraster.rs`).
@@ -591,6 +592,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| 2026-10-14 | minor | Tools, UI and effects: Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663); The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787); Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757); Slider rails show on Medium Dark (#1808, #1662); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation); Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648); correction to the 2026-10-13 rows: a guide's position in the Transform panel (#1659) is not established by the documentation found (help pages read again on 2026-10-14) and is kept as a convenience pending the owner's decision, with no parity claim; scores unchanged (estimated) |
| 2026-10-14 | minor | Files, untrusted input and automation: EPS import: a stitched shading keeps its hard colour changes (#1654, #1653); WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774); Linked files that change during a drag are updated after the drag instead of being missed (#1773); Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780); One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779); A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775); Tests: text import fuzzing no longer searches installed fonts (#1785); Tests: render tests that add fonts no longer race the frame cache tests (#1795); `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748); MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727); MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728); The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739); #1684 and #1797 wrote their own evidence; scores unchanged |
| 2026-10-14 | minor | Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px); the Image Trace row keeps 75% (estimated): the score was raised for the measured work on 2026-10-13 and these two are refinements of it |
| 2026-10-14 | minor | Malformed JSON app replies drop the MCP connection without replay and report uncertain completion (#1761); upstream notification and id-less refusal behavior preserved; scores and estimates unchanged. |
| 2026-10-13 | minor | Files: SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620); A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604); A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613); scores unchanged (estimated) |
| 2026-10-13 | minor | Tools, UI, effects and type: Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570); Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590); The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560); Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616); Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't); The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score; Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571); Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575); Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558); Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578); `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592); Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615); Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607); The Polish translator confirmed the Polish rows the maintainers had added after the catalog was written (#1614); scores unchanged (estimated) |
| 2026-10-13 | minor | PDF.R1: editable PDF import uses the shared pinned reader; layer and overprint scans preserve state across content windows and reject known incomplete input. Synthetic streaming, decoder-limit and dependency-source regressions added; scores and hours unchanged. |