feat: web automation wrapper (#1349), DATALINK table on web, System theme; honest decode accounting

Ported from 2c38de53 (#1717). Main already carries the hatch PDF draw
order fix (#1345), the numeric set_properties compare (#1348) and the
web open from bytes, so those parts stay out; what remains:

#1349 — window.ocsControl.ready/request promise wrapper with an
ocs-ready event (web/ocs-control.js, loaded by the web page), and the
startup donation prompt is suppressed once a control request is seen
(opt-in pre-boot via localStorage["opencadstudio.automation"] = "1").
docs/automation/web.md documents both. The control protocol stays at 1.

DATALINK CSV-to-table works on web (picker + bytes through the session
registry — was std::fs, silently dead); COLORSCHEME accepts System
(prefers-color-scheme on web, AppsUseLightTheme on Windows).

Decode accounting, made honest per the fix-of-fix review: into_rgba8
allocates the RGBA destination while the native buffer is alive for
every non-Rgba8 layout (L8 5, La8 6, Rgb8 7, L16 6, La16 8 B/px
peaks — the formula now says so), GIF bills the screen+frame
coexistence at 8 B/px, and TIFF drops the 3x multipliers both ways —
tiff 0.11 splits the given budget between frame and scratch (a total
ceiling), so the old double-billing refused honest 16-bit GeoTIFFs
~2.25x early while admitting hostile chunk transients at 3x.

(cherry picked from commit 2c38de53)
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:05 +03:00
1 parent f015c3b5fa
commit db8db14006
37 files changed
+436 -52

No files matched your search

+28
View File
@@ -1298,6 +1298,34 @@ impl OpenCADStudio {
Task::none()
}
/// Web DATALINK: the picked .csv's bytes arrived. They are registered in
/// the picked-source registry — so `DATALINKUPDATE` re-reads them
/// in-session instead of failing on the absent filesystem — and fed to
/// the same table-insert path the native typed-path command uses.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_datalink_csv_picked(
&mut self,
picked: Option<(String, Vec<u8>)>,
) -> Task<Message> {
let Some((name, bytes)) = picked else {
return Task::none();
};
let text = String::from_utf8_lossy(&bytes).into_owned();
if !register_picked_source(&name, Arc::new(bytes)) {
if let Some(name) = take_last_collision().take() {
self.command_line.push_warning(
crate::tf!(
"\"{name}\" is already loaded with different contents; the existing file was kept."
)
.as_ref(),
);
}
return Task::none();
}
self.place_data_link_table(&text, &name);
Task::none()
}
pub(super) fn on_data_link_browse(&mut self) -> Task<Message> {
Task::perform(
async {
+59 -40
View File
@@ -1799,51 +1799,25 @@ impl OpenCADStudio {
.push_info(crate::t!("Usage: DATALINK <path-to-.csv>").as_ref());
return Some(Task::none());
}
// Web: nothing is behind a typed path — pick the .csv through
// the browser, read its bytes at pick time, and register them
// in the picked-source registry; the same table-insert path
// then runs from `DataLinkCsvPicked`.
#[cfg(target_arch = "wasm32")]
{
let _ = path;
return Some(Task::perform(
crate::io::pick_datalink_csv_web(),
Message::DataLinkCsvPicked,
));
}
#[cfg(not(target_arch = "wasm32"))]
match std::fs::read_to_string(path) {
Ok(text) => {
let rows_data = parse_csv_table(&text);
let nrows = rows_data.len();
let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0);
if nrows == 0 || ncols == 0 {
self.command_line
.push_error(crate::t!("DATALINK: the CSV file is empty.").as_ref());
return Some(Task::none());
}
use codec::entities::TableBuilder;
use codec::types::Vector3;
let mut table = TableBuilder::new(nrows, ncols)
.at(Vector3::new(0.0, 0.0, 0.0))
.row_height(0.5)
.column_width(2.0)
.build();
for (r, row) in rows_data.iter().enumerate() {
for (c, cell) in row.iter().enumerate() {
table.set_cell_text(r, c, cell);
}
}
let doc = &self.tabs[i].scene.document;
let current_style = doc.header.current_table_style_name.clone();
table.table_style_handle =
doc.objects
.iter()
.find_map(|(handle, object)| match object {
codec::objects::ObjectType::TableStyle(style)
if style.name.eq_ignore_ascii_case(&current_style) =>
{
Some(*handle)
}
_ => None,
});
let link_path = std::fs::canonicalize(path)
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|_| path.to_string());
let command =
crate::modules::annotate::data_link::DataLinkPlaceCommand::new(
table, &link_path,
);
self.command_line
.push_info(&crate::command::CadCommand::prompt(&command));
self.tabs[i].active_cmd = Some(Box::new(command));
self.place_data_link_table(&text, &link_path);
}
Err(e) => {
self.command_line.push_error(
@@ -2076,6 +2050,51 @@ impl OpenCADStudio {
}
Some(self.finish_dispatch(cmd))
}
/// Build the linked table from CSV text and start the placement command —
/// the shared tail of the native typed-path `DATALINK` read and the web
/// picker's `DataLinkCsvPicked` (which registers the picked bytes in the
/// picked-source registry first so the link re-reads in-session).
pub(in crate::app) fn place_data_link_table(&mut self, csv_text: &str, link_path: &str) {
let i = self.active_tab;
let rows_data = parse_csv_table(csv_text);
let nrows = rows_data.len();
let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0);
if nrows == 0 || ncols == 0 {
self.command_line
.push_error(crate::t!("DATALINK: the CSV file is empty.").as_ref());
return;
}
use codec::entities::TableBuilder;
use codec::types::Vector3;
let mut table = TableBuilder::new(nrows, ncols)
.at(Vector3::new(0.0, 0.0, 0.0))
.row_height(0.5)
.column_width(2.0)
.build();
for (r, row) in rows_data.iter().enumerate() {
for (c, cell) in row.iter().enumerate() {
table.set_cell_text(r, c, cell);
}
}
let doc = &self.tabs[i].scene.document;
let current_style = doc.header.current_table_style_name.clone();
table.table_style_handle = doc.objects.iter().find_map(|(handle, object)| {
match object {
codec::objects::ObjectType::TableStyle(style)
if style.name.eq_ignore_ascii_case(&current_style) =>
{
Some(*handle)
}
_ => None,
}
});
let command =
crate::modules::annotate::data_link::DataLinkPlaceCommand::new(table, link_path);
self.command_line
.push_info(&crate::command::CadCommand::prompt(&command));
self.tabs[i].active_cmd = Some(Box::new(command));
}
}
// Scan LandXML text for <CgPoint> survey points. Each element's text content is
+27 -2
View File
@@ -535,7 +535,8 @@ impl OpenCADStudio {
"Available themes: {}",
"DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA",
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA \
SYSTEM (follows the OS or browser color scheme)",
));
self.command_line.push_info(&crate::tf!(
"Current color scheme: <{}>",
@@ -559,7 +560,8 @@ impl OpenCADStudio {
self.command_line.push_output(&crate::tf!("Available themes: {}",
"DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA",
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA \
SYSTEM (follows the OS or browser color scheme)",
));
return Some(Task::none());
}
@@ -569,6 +571,29 @@ impl OpenCADStudio {
);
return Some(Task::none());
}
// SYSTEM keeps its own name in the saved config and resolves
// to a concrete Light/Dark theme at run time — the browser's
// prefers-color-scheme on the web, the Windows personalization
// setting natively. Re-running the command re-resolves; there
// is no live preference listener.
if upper_sub == "SYSTEM" {
if self.ui_theme.name == "Custom" {
self.saved_custom_palette = Some(self.ui_theme.palette);
}
let resolved = crate::app::config::resolve_system_theme();
let name = format!("{resolved:?}");
self.ui_theme.name = "System".to_string();
self.ui_theme.palette =
crate::app::config::UiThemePalette::from_iced(resolved.seed());
self.theme_color_inputs = self.ui_theme.palette.hex_values();
self.active_theme = resolved;
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
self.command_line.push_output(crate::tf!(
"Color scheme set to 'System' (currently {name})."
).as_ref());
return Some(Task::none());
}
let theme = crate::app::config::parse_theme_name(sub);
if let Some(t) = theme {
if self.ui_theme.name == "Custom" {
+84
View File
@@ -109,12 +109,77 @@ impl UiThemeConfig {
pub fn to_iced(&self) -> iced::Theme {
if self.name == "Custom" {
iced::Theme::custom("Custom", self.palette.to_iced())
} else if self.name.eq_ignore_ascii_case("System") {
resolve_system_theme()
} else {
builtin_theme(&self.name).unwrap_or(iced::Theme::Oxocarbon)
}
}
}
/// Resolve the `System` color scheme (COLORSCHEME SYSTEM) to a concrete
/// theme: the browser `prefers-color-scheme` media query on the web, the
/// Windows `AppsUseLightTheme` personalization value natively, and the dark
/// default on the remaining native platforms (no cheap OS query there).
/// Read at boot (`apply_config`) and re-read whenever COLORSCHEME SYSTEM
/// runs again — there is no live preference listener by design.
pub fn resolve_system_theme() -> iced::Theme {
#[cfg(target_arch = "wasm32")]
{
let light = web_sys::window().is_some_and(|window| {
window
.match_media("(prefers-color-scheme: light)")
.ok()
.flatten()
.is_some_and(|query| query.matches())
});
if light {
iced::Theme::Light
} else {
iced::Theme::Dark
}
}
#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))]
{
use windows_sys::Win32::System::Registry::{
RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_DWORD,
};
let sub_key: Vec<u16> = "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let value: Vec<u16> = "AppsUseLightTheme"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut data: u32 = 0;
let mut size = std::mem::size_of::<u32>() as u32;
// ERROR_SUCCESS (0) with data 1 means the apps theme is Light; a
// missing value (older Windows, wiped personalization) is Dark.
let light = unsafe {
RegGetValueW(
HKEY_CURRENT_USER,
sub_key.as_ptr(),
value.as_ptr(),
RRF_RT_REG_DWORD,
std::ptr::null_mut(),
&mut data as *mut u32 as *mut core::ffi::c_void,
&mut size,
) == 0
&& data == 1
};
if light {
iced::Theme::Light
} else {
iced::Theme::Dark
}
}
#[cfg(not(any(target_arch = "wasm32", target_os = "windows")))]
{
iced::Theme::Dark
}
}
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct UiThemePalette {
@@ -538,6 +603,25 @@ fn config_path() -> Option<std::path::PathBuf> {
mod tests {
use super::*;
#[test]
fn system_scheme_resolves_to_a_concrete_theme_and_keeps_its_name() {
// Fresh configs keep the existing default; SYSTEM is opt-in.
assert_ne!(UiThemeConfig::default().name, "System");
let mut cfg = UiThemeConfig::default();
cfg.name = "System".to_string();
let resolved = cfg.to_iced();
// Case-insensitive like the other stored names.
cfg.name = "system".to_string();
assert_eq!(cfg.to_iced(), resolved);
assert!(
matches!(resolved, iced::Theme::Light | iced::Theme::Dark),
"SYSTEM must resolve through the platform preference"
);
// The stored name survives resolution; re-runs re-resolve.
assert_eq!(cfg.name, "system");
assert_eq!(resolve_system_theme(), resolved);
}
#[test]
fn test_model_space_defaults() {
let cfg = ModelSpaceThemeConfig::default();
+16
View File
@@ -106,6 +106,10 @@ pub(super) struct State {
completed: VecDeque<(String, Value, Value)>,
owner: Option<(u64, String)>,
pub enabled: bool,
/// Set by the first control request of the session (or by a web host's
/// boot-time opt-in): while control is enabled, startup prompts that
/// would block automation — the donation prompt — are suppressed (#1349).
pub automation_seen: bool,
serial: u64,
events: VecDeque<Value>,
/// Monotonic counter bumped on every real selection change, so clients
@@ -489,6 +493,18 @@ impl OpenCADStudio {
}
pub(super) fn control_request(&mut self, req: Value) -> (Value, Task<Message>) {
// First control contact marks the session automated (#1349): a
// DonationPrompt that is still queued (not yet shown) is dropped so
// automation never waits behind it, and `queue_startup_prompts`
// stops queueing one. A prompt already on screen stays — a human
// may be reading it; clients close it with `close_modal`.
if !self.control.automation_seen && self.control.enabled {
self.control.automation_seen = true;
if self.active_modal != Some(super::ModalKind::DonationPrompt) {
self.pending_startup_modals
.retain(|kind| kind != &super::ModalKind::DonationPrompt);
}
}
let op = req["op"].as_str().unwrap_or("");
let query = matches!(
op,
+15
View File
@@ -2754,6 +2754,11 @@ pub enum Message {
/// `None` = cancelled.
#[cfg(target_arch = "wasm32")]
CenterlineLinetypeFilePicked(Option<(String, String)>),
/// Web DATALINK: the picked .csv file's name and raw bytes — registered
/// in the picked-source registry, then fed to the same table-insert path
/// the native typed-path command uses. `None` = cancelled.
#[cfg(target_arch = "wasm32")]
DataLinkCsvPicked(Option<(String, Vec<u8>)>),
/// Append one typed character to the command-line input from the
/// global key-press subscription. Used when the text-input widget
/// itself isn't focused (focus parked on viewport / button / etc.)
@@ -5079,6 +5084,16 @@ impl OpenCADStudio {
fn boot_web() -> (Self, Task<Message>) {
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut s = Self::new();
// A web host that intends to automate can opt in before the first
// control request by setting `opencadstudio.automation` to "1" in
// localStorage (the same pre-load mechanism the settings use): the
// startup donation prompt then never queues (#1349).
if web_sys::window().and_then(|window| window.local_storage().ok().flatten()).and_then(
|storage| storage.get_item("opencadstudio.automation").ok().flatten(),
) == Some("1".to_string())
{
s.control.automation_seen = true;
}
s.queue_startup_prompts();
let focus = s.focus_cmd_input();
let primary_font =
+40 -1
View File
@@ -8,7 +8,13 @@ impl OpenCADStudio {
self.pending_startup_modals
.push_back(ModalKind::AssocPrompt);
}
if self.donation_prompt_version != env!("OCS_APP_VERSION") {
// Automation sessions (#1349): once control traffic has arrived (or a
// web host opted in at boot) while control is enabled, the donation
// prompt is suppressed — a modal would block every automated
// operation until a client closed it by hand.
if self.donation_prompt_version != env!("OCS_APP_VERSION")
&& !(self.control.enabled && self.control.automation_seen)
{
self.pending_startup_modals
.push_back(ModalKind::DonationPrompt);
}
@@ -226,6 +232,39 @@ mod tests {
assert!(restarted.active_modal.is_none());
}
#[test]
fn automation_sessions_skip_the_donation_prompt() {
// First control contact drops a still-queued donation prompt and
// stops later queueing while control stays enabled (#1349).
let mut app = OpenCADStudio::new_for_test();
app.default_assoc_prompted = true;
app.queue_startup_prompts();
assert_eq!(app.active_modal, Some(ModalKind::DonationPrompt));
let _ = app.update(Message::CloseModal);
app.donation_prompt_version = "previous-release".to_string();
app.pending_startup_modals.push_back(ModalKind::DonationPrompt);
let _ = app.control_request(serde_json::json!({"op":"state"}));
assert!(app.pending_startup_modals.is_empty());
app.queue_startup_prompts();
assert_ne!(app.active_modal, Some(ModalKind::DonationPrompt));
// A still-queued (not yet shown) prompt is dropped on first contact.
let mut queued = OpenCADStudio::new_for_test();
queued.default_assoc_prompted = true;
queued.donation_prompt_version = "previous-release".to_string();
queued.pending_startup_modals.push_back(ModalKind::DonationPrompt);
let _ = queued.control_request(serde_json::json!({"op":"state"}));
queued.queue_startup_prompts();
assert_ne!(queued.active_modal, Some(ModalKind::DonationPrompt));
// A prompt already on screen is left for its reader; clients close
// it themselves, and a session with no control traffic still prompts.
let mut human = OpenCADStudio::new_for_test();
human.default_assoc_prompted = true;
human.queue_startup_prompts();
assert_eq!(human.active_modal, Some(ModalKind::DonationPrompt));
}
#[test]
fn startup_dialogs_wait_their_turn_and_preserve_unseen_prompts() {
let mut app = OpenCADStudio::new_for_test();
+8
View File
@@ -1348,6 +1348,14 @@ impl OpenCADStudio {
pub(in crate::app) fn apply_config(&mut self, cfg: crate::app::config::AppConfig) {
self.apply_settings(&cfg.settings);
self.ui_theme = cfg.theme.clone();
// A saved `System` scheme re-resolves at boot: the stored palette is
// whatever the last resolution produced, so refresh it alongside the
// live theme (COLORSCHEME SYSTEM documents this boot-time read).
if self.ui_theme.name.eq_ignore_ascii_case("System") {
self.ui_theme.palette = crate::app::config::UiThemePalette::from_iced(
crate::app::config::resolve_system_theme().seed(),
);
}
self.active_theme = self.ui_theme.to_iced();
self.theme_color_inputs = self.ui_theme.palette.hex_values();
self.model_space = cfg.model_space;
+6
View File
@@ -2964,6 +2964,12 @@ impl OpenCADStudio {
#[cfg(target_arch = "wasm32")]
Message::ScriptFilePicked(None) => Task::none(),
// Web DATALINK: the picked .csv's bytes arrived — register them
// as the link's in-session source, then run the same table-insert
// path the native typed-path command feeds.
#[cfg(target_arch = "wasm32")]
Message::DataLinkCsvPicked(picked) => self.on_datalink_csv_picked(picked),
// Web CENTERLTYPEFILE: the picked .lin/.dnl file's text arrived —
// load its linetypes, then record the setting under the file's
// name (the native command reads the typed path inside
+21
View File
@@ -273,6 +273,27 @@ pub async fn pick_linetype_file_web() -> Option<(String, String)> {
Some((name, String::from_utf8_lossy(&bytes).into_owned()))
}
/// Web DATALINK: pick the .csv through the browser and read its raw bytes —
/// the stand-in for the typed path the native command reads with
/// `std::fs::read_to_string`. The caller registers the bytes in the
/// picked-source registry so the link re-reads in-session. `None` = the user
/// cancelled.
#[cfg(target_arch = "wasm32")]
pub async fn pick_datalink_csv_web() -> Option<(String, Vec<u8>)> {
let Some(handle) = crate::sys::file_dialog()
.set_title(crate::t!("Link a CSV data source").as_ref())
.add_filter(crate::t!("CSV Files").as_ref(), &["csv", "txt", "CSV", "TXT"])
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
else {
return None;
};
let name = handle.file_name();
let bytes = handle.read().await;
Some((name, bytes))
}
/// Load a CAD file from a known path. Parsing and cache building run on a
/// dedicated OS thread so the async executor stays free for rendering during
/// the load. Writes phase markers into `phase` so the UI can show
+3
View File
@@ -1013,6 +1013,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Color Temperature" => Some(("properties", "color-temperature")),
"Color override (#RRGGBB):" => Some(("styles", "color-override-rrggbb")),
"Color scheme set to '{name}'." => Some(("view", "color-scheme-set-to-name")),
"Color scheme set to 'System' (currently {name})." => Some(("view", "color-scheme-set-to-system-currently-name")),
"Color {}" => Some(("ui", "color")),
"Column count" => Some(("properties", "column-count")),
"Column gutter" => Some(("properties", "column-gutter")),
@@ -4187,6 +4188,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Data Extraction Settings" => Some(("common", "data-extraction-settings")),
"Add Drawings" => Some(("common", "add-drawings")),
"CSV" => Some(("common", "csv")),
"CSV Files" => Some(("common", "csv-files")),
"Link a CSV data source" => Some(("common", "link-a-csv-data-source")),
"Pick the file again to load its contents in the browser." => Some(("file", "pick-the-file-again-to-load-its-contents")),
"Adding folders is not available in the browser. Add drawing files instead." => Some(("file", "adding-folders-is-not-available-in-the-browser")),
"Drawing not loaded in the browser: {name}." => Some(("file", "drawing-not-loaded-in-the-browser")),
+18 -7
View File
@@ -772,19 +772,27 @@ fn native_footprint(format: Option<image::ImageFormat>, color: Option<image::Col
image::ColorType::Rgba32F => 16,
_ => 4,
});
// A 16-bit frame and its RGBA conversion can coexist; an 8-bit one is
// converted with at most a small row buffer in flight.
let peak_bpp = if native_bpp > 4 { native_bpp + 4 } else { 4 };
let codec_factor = match format {
Some(image::ImageFormat::Tiff) => 3,
_ => 1,
// `DynamicImage::into_rgba8` only MOVES for Rgba8; every other layout
// allocates the RGBA destination while the native buffer is still
// alive, so both coexist (image 0.25 buffer.rs cast_in_color_space).
let peak_bpp = if native_bpp == 4 { 4 } else { native_bpp + 4 };
// GIF decodes the frame into its own buffer while the screen-sized
// output buffer is live — two RGBA-sized allocations coexist.
let peak_bpp = match format {
Some(image::ImageFormat::Gif) => peak_bpp.max(8),
_ => peak_bpp,
};
// tiff 0.11 splits the given budget between the frame buffer and the
// chunk scratch (a TOTAL cap), so no multiplier is needed: the honest
// peak is the coexisting native+RGBA pair, already in peak_bpp.
let codec_factor = 1;
NativeFootprint { peak_bpp, codec_factor }
}
/// Budget decision for probed pixel dimensions. Pure and cfg-independent
/// when the budgets are passed explicitly, so tests can probe arbitrary
/// budgets; the decode path always passes this target's constants.
#[cfg_attr(not(test), allow(dead_code))]
fn tier_for(
width: u32,
height: u32,
@@ -915,6 +923,10 @@ fn decode_with_budgets<R: std::io::BufRead + std::io::Seek>(
// 0.25); the tier already folded that factor in, and the allowance
// matches it so an in-tier TIFF never codec-refuses.
let mut limits = image::Limits::default();
// tiff 0.11 SPLITS this cap between the frame buffer and chunk scratch
// (a total ceiling), so the plain budget is the right allowance; the
// ×3 multiplier that image's older tiff codec needed would instead
// admit hostile chunk transients up to 3× the budget.
limits.max_alloc = Some(match reader.format() {
Some(image::ImageFormat::Gif) => probed
.map(|(width, height, _, _)| {
@@ -922,7 +934,6 @@ fn decode_with_budgets<R: std::io::BufRead + std::io::Seek>(
})
.unwrap_or(max_decode_bytes)
.min(max_decode_bytes),
Some(image::ImageFormat::Tiff) => max_decode_bytes.saturating_mul(3),
_ => max_decode_bytes,
});
reader.limits(limits);