dsh-ops 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +435 -0
  5. package/README.en.md +126 -0
  6. package/README.md +115 -2
  7. package/README.zh.md +116 -0
  8. package/bin/dsh-ops.mjs +1216 -0
  9. package/cordis.patch.yml +160 -0
  10. package/docs/manual-validation.md +53 -0
  11. package/docs/release-0.2.1.md +72 -0
  12. package/docs/schema-baseline.json +64 -0
  13. package/docs/schema-current.json +84 -0
  14. package/docs/schema-measurement.md +17 -0
  15. package/dsh-plugin.json +88 -0
  16. package/icon.svg +12 -0
  17. package/lib/binary.js +409 -0
  18. package/lib/config.js +198 -0
  19. package/lib/handshake.js +252 -0
  20. package/lib/index.js +108 -0
  21. package/lib/jobs.js +42 -0
  22. package/lib/policy.js +64 -0
  23. package/lib/presentation.js +63 -0
  24. package/lib/profile-install.js +61 -0
  25. package/lib/rust.js +194 -0
  26. package/lib/session-shells.js +78 -0
  27. package/lib/shells.js +998 -0
  28. package/lib/tools.js +657 -0
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +114 -4
  32. package/vendor/fastctx/Cargo.lock +3210 -0
  33. package/vendor/fastctx/Cargo.toml +94 -0
  34. package/vendor/fastctx/FORK.md +119 -0
  35. package/vendor/fastctx/LICENSE-APACHE +201 -0
  36. package/vendor/fastctx/NOTICE +40 -0
  37. package/vendor/fastctx/README.md +439 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  39. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  40. package/vendor/fastctx/UPSTREAM.md +49 -0
  41. package/vendor/fastctx/build.rs +413 -0
  42. package/vendor/fastctx/src/background_status.rs +403 -0
  43. package/vendor/fastctx/src/binary.rs +75 -0
  44. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  45. package/vendor/fastctx/src/budget.rs +781 -0
  46. package/vendor/fastctx/src/cli/mod.rs +110 -0
  47. package/vendor/fastctx/src/context_guard.rs +289 -0
  48. package/vendor/fastctx/src/control/mod.rs +6 -0
  49. package/vendor/fastctx/src/control/paths.rs +49 -0
  50. package/vendor/fastctx/src/control/settings.rs +753 -0
  51. package/vendor/fastctx/src/control/transaction.rs +531 -0
  52. package/vendor/fastctx/src/edit/document.rs +535 -0
  53. package/vendor/fastctx/src/edit/locks.rs +371 -0
  54. package/vendor/fastctx/src/edit/mod.rs +213 -0
  55. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  56. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  57. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  58. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  59. package/vendor/fastctx/src/edit_server.rs +53 -0
  60. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  61. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  62. package/vendor/fastctx/src/encoding.rs +1118 -0
  63. package/vendor/fastctx/src/file_executor.rs +1151 -0
  64. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  65. package/vendor/fastctx/src/glob_filter.rs +98 -0
  66. package/vendor/fastctx/src/glob_tool.rs +653 -0
  67. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  68. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  69. package/vendor/fastctx/src/lib.rs +45 -0
  70. package/vendor/fastctx/src/main.rs +15 -0
  71. package/vendor/fastctx/src/model.rs +51 -0
  72. package/vendor/fastctx/src/model_guidance.rs +62 -0
  73. package/vendor/fastctx/src/operation.rs +356 -0
  74. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  75. package/vendor/fastctx/src/os_environment.rs +414 -0
  76. package/vendor/fastctx/src/path_codec.rs +850 -0
  77. package/vendor/fastctx/src/paths.rs +244 -0
  78. package/vendor/fastctx/src/process_identity.rs +763 -0
  79. package/vendor/fastctx/src/process_policy.rs +74 -0
  80. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  81. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  82. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  83. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  84. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  85. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  86. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  87. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  88. package/vendor/fastctx/src/render_plan.rs +468 -0
  89. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  90. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  91. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  92. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  93. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  94. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  95. package/vendor/fastctx/src/runtime/session.rs +536 -0
  96. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  97. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  98. package/vendor/fastctx/src/search_text.rs +227 -0
  99. package/vendor/fastctx/src/server.rs +359 -0
  100. package/vendor/fastctx/src/server_manifest.rs +468 -0
  101. package/vendor/fastctx/src/server_support.rs +826 -0
  102. package/vendor/fastctx/src/session.rs +629 -0
  103. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  104. package/vendor/fastctx/src/shell/bash.rs +263 -0
  105. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  106. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  107. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  108. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  109. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  110. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  111. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  112. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  113. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  114. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  115. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  116. package/vendor/fastctx/src/shell/mod.rs +345 -0
  117. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  118. package/vendor/fastctx/src/shell/output.rs +406 -0
  119. package/vendor/fastctx/src/shell/process.rs +493 -0
  120. package/vendor/fastctx/src/shell_server.rs +156 -0
  121. package/vendor/fastctx/src/skip_report.rs +83 -0
  122. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  123. package/vendor/fastctx/src/tool_schema.rs +204 -0
  124. package/vendor/fastctx/src/traversal.rs +846 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  140. package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
@@ -0,0 +1,470 @@
1
+ //! PDF page selection, text extraction, and 150-DPI page rendering.
2
+
3
+ use crate::budget::{TokenBudget, assemble_text, estimate_tokens};
4
+ use crate::model::{ImageDetail, ToolContent, ToolResponse};
5
+ use crate::read_tool::pdf_engine::{PdfOperationError, pdfium_session, run_pdf_operation};
6
+ use base64::Engine;
7
+ use image::ImageFormat;
8
+ use pdfium_render::prelude::{PdfDocument, PdfRenderConfig, PdfiumError, PdfiumInternalError};
9
+ use schemars::JsonSchema;
10
+ use serde::Deserialize;
11
+ use std::io::Cursor;
12
+ use std::path::Path;
13
+
14
+ const DEFAULT_MAX_PAGES: usize = 10;
15
+ const DEFAULT_IMAGE_PAGES: usize = 4;
16
+ const MAX_PAGES_PER_CALL: usize = 20;
17
+ const RENDER_DPI: f32 = 150.0;
18
+ const MAX_RENDER_DIMENSION: u64 = 16_384;
19
+ const MAX_PAGE_PIXELS: u64 = 32_000_000;
20
+ const MAX_CALL_PIXELS: u64 = 64_000_000;
21
+ const MAX_IMAGE_PAYLOAD_BYTES: usize = 8 * 1024 * 1024;
22
+
23
+ /// Mutually exclusive PDF response channels.
24
+ #[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Eq, PartialEq)]
25
+ #[serde(rename_all = "lowercase")]
26
+ pub(super) enum PdfMode {
27
+ /// Return only the text layer of the selected pages.
28
+ #[default]
29
+ Text,
30
+ /// Return only full-page PNG images for the selected pages.
31
+ Image,
32
+ }
33
+
34
+ #[derive(Debug)]
35
+ struct TextPage {
36
+ number: usize,
37
+ text: String,
38
+ }
39
+
40
+ #[derive(Clone, Copy, Debug)]
41
+ struct RenderPlan {
42
+ number: usize,
43
+ width: i32,
44
+ height: i32,
45
+ }
46
+
47
+ pub(super) fn is_pdf(path: &Path, bytes: &[u8]) -> bool {
48
+ bytes.starts_with(b"%PDF")
49
+ || path
50
+ .extension()
51
+ .and_then(|extension| extension.to_str())
52
+ .is_some_and(|extension| extension.eq_ignore_ascii_case("pdf"))
53
+ }
54
+
55
+ pub(super) fn parse_pdf_mode(value: Option<&str>) -> Result<PdfMode, String> {
56
+ match value {
57
+ None | Some("text") => Ok(PdfMode::Text),
58
+ Some("image") => Ok(PdfMode::Image),
59
+ Some(value) => Err(format!(
60
+ "Invalid pdf_mode value \"{value}\". Use \"text\" or \"image\"."
61
+ )),
62
+ }
63
+ }
64
+
65
+ pub(super) fn read_pdf(
66
+ path: &Path,
67
+ pages_value: Option<&str>,
68
+ mode: PdfMode,
69
+ text_budget: Option<TokenBudget>,
70
+ ) -> ToolResponse {
71
+ let path = path.to_path_buf();
72
+ let path_display = crate::paths::display_path(&path);
73
+ let pages_value = pages_value.map(str::to_string);
74
+ match run_pdf_operation(move || {
75
+ read_pdf_inner(&path, pages_value.as_deref(), mode, text_budget)
76
+ }) {
77
+ Ok(response) => response,
78
+ Err(PdfOperationError::TimedOut) => ToolResponse::error(format!(
79
+ "PDF operation on {path_display} timed out and was aborted. The file may be malformed; other file types are unaffected."
80
+ )),
81
+ Err(PdfOperationError::Unavailable(reason)) => pdf_engine_error(&reason),
82
+ }
83
+ }
84
+
85
+ fn read_pdf_inner(
86
+ path: &Path,
87
+ pages_value: Option<&str>,
88
+ mode: PdfMode,
89
+ text_budget: Option<TokenBudget>,
90
+ ) -> ToolResponse {
91
+ let (_operation, pdfium) = match pdfium_session() {
92
+ Ok(session) => session,
93
+ Err(reason) => return pdf_engine_error(&reason),
94
+ };
95
+ let document = match pdfium.load_pdf_from_file(path, None) {
96
+ Ok(document) => document,
97
+ Err(error) => return pdf_load_error(error),
98
+ };
99
+ let total_pages = document.pages().len() as usize;
100
+ if total_pages == 0 {
101
+ return corrupted_pdf();
102
+ }
103
+ let selected = match parse_pages(pages_value, total_pages, mode) {
104
+ Ok(selected) => selected,
105
+ Err(message) => return ToolResponse::error(message),
106
+ };
107
+
108
+ match mode {
109
+ PdfMode::Text => read_pdf_text(
110
+ &document,
111
+ &selected,
112
+ total_pages,
113
+ text_budget.expect("text mode always receives a token budget"),
114
+ ),
115
+ PdfMode::Image => read_pdf_images(&document, &selected, total_pages),
116
+ }
117
+ }
118
+
119
+ fn read_pdf_text(
120
+ document: &PdfDocument<'_>,
121
+ selected: &[usize],
122
+ total_pages: usize,
123
+ budget: TokenBudget,
124
+ ) -> ToolResponse {
125
+ let mut pages = Vec::with_capacity(selected.len());
126
+ for page_number in selected {
127
+ let page = match document.pages().get((*page_number - 1) as i32) {
128
+ Ok(page) => page,
129
+ Err(_) => return corrupted_pdf(),
130
+ };
131
+ let text = match page.text() {
132
+ Ok(text) => normalize_pdf_text(&text.all()),
133
+ Err(_) => return corrupted_pdf(),
134
+ };
135
+ pages.push(TextPage {
136
+ number: *page_number,
137
+ text,
138
+ });
139
+ }
140
+ format_text_pages(&pages, total_pages, budget)
141
+ }
142
+
143
+ fn format_text_pages(pages: &[TextPage], total_pages: usize, budget: TokenBudget) -> ToolResponse {
144
+ let selected_all_no_text = pages.iter().all(|page| page.text.trim().is_empty());
145
+ for shown in (1..=pages.len()).rev() {
146
+ let output = render_text_output(&pages[..shown], total_pages, selected_all_no_text, false);
147
+ if estimate_tokens(&output) <= budget.value {
148
+ return ToolResponse::text(output);
149
+ }
150
+ }
151
+
152
+ truncate_first_text_page(&pages[0], total_pages, budget)
153
+ }
154
+
155
+ fn truncate_first_text_page(
156
+ page: &TextPage,
157
+ total_pages: usize,
158
+ budget: TokenBudget,
159
+ ) -> ToolResponse {
160
+ if page.text.trim().is_empty() {
161
+ return budget_too_small(budget);
162
+ }
163
+ let lines = page.text.split('\n').collect::<Vec<_>>();
164
+ let mut low = 0_usize;
165
+ let mut high = lines.len().saturating_sub(1);
166
+ let mut best = None;
167
+ while low <= high {
168
+ let count = low + (high - low) / 2;
169
+ let text = lines[..count].join("\n");
170
+ let truncated = TextPage {
171
+ number: page.number,
172
+ text,
173
+ };
174
+ let output = render_text_output(&[truncated], total_pages, false, true);
175
+ if estimate_tokens(&output) <= budget.value {
176
+ best = Some(output);
177
+ low = count.saturating_add(1);
178
+ } else if count == 0 {
179
+ break;
180
+ } else {
181
+ high = count - 1;
182
+ }
183
+ }
184
+
185
+ match best {
186
+ Some(output) => ToolResponse::text(output),
187
+ None => budget_too_small(budget),
188
+ }
189
+ }
190
+
191
+ fn render_text_output(
192
+ pages: &[TextPage],
193
+ total_pages: usize,
194
+ selected_all_no_text: bool,
195
+ first_page_truncated: bool,
196
+ ) -> String {
197
+ let body = pages
198
+ .iter()
199
+ .map(text_page_block)
200
+ .collect::<Vec<_>>()
201
+ .join("\n\n");
202
+ let mut notes = Vec::new();
203
+ if selected_all_no_text {
204
+ notes.push(
205
+ "(Note: no text layer in the selected pages; use pdf_mode=\"image\" to view rendered pages.)"
206
+ .to_string(),
207
+ );
208
+ }
209
+ if first_page_truncated {
210
+ notes.push(format!(
211
+ "(Note: page {} text truncated at the token budget; use pdf_mode=\"image\" to view the full page.)",
212
+ pages[0].number
213
+ ));
214
+ }
215
+ notes.push(pdf_terminal_note(
216
+ pages[0].number,
217
+ pages.last().expect("text response has a page").number,
218
+ total_pages,
219
+ PdfMode::Text,
220
+ pages.len(),
221
+ ));
222
+ assemble_text(&[body], &notes)
223
+ }
224
+
225
+ fn text_page_block(page: &TextPage) -> String {
226
+ if page.text.trim().is_empty() {
227
+ format!("=== Page {} === (no text layer)", page.number)
228
+ } else {
229
+ format!("=== Page {} ===\n{}", page.number, page.text)
230
+ }
231
+ }
232
+
233
+ fn read_pdf_images(
234
+ document: &PdfDocument<'_>,
235
+ selected: &[usize],
236
+ total_pages: usize,
237
+ ) -> ToolResponse {
238
+ let plans = match preflight_render_plans(document, selected) {
239
+ Ok(plans) => plans,
240
+ Err(response) => return response,
241
+ };
242
+ collect_encoded_images(&plans, total_pages, MAX_IMAGE_PAYLOAD_BYTES, |plan| {
243
+ encode_page_png(document, plan)
244
+ })
245
+ }
246
+
247
+ fn preflight_render_plans(
248
+ document: &PdfDocument<'_>,
249
+ selected: &[usize],
250
+ ) -> Result<Vec<RenderPlan>, ToolResponse> {
251
+ let mut plans = Vec::with_capacity(selected.len());
252
+ let mut call_pixels = 0_u64;
253
+ for page_number in selected {
254
+ let page = document
255
+ .pages()
256
+ .get((*page_number - 1) as i32)
257
+ .map_err(|_| corrupted_pdf())?;
258
+ let (width, height, pixels) =
259
+ render_dimensions(*page_number, page.width().value, page.height().value)?;
260
+ call_pixels = call_pixels.saturating_add(pixels);
261
+ if call_pixels > MAX_CALL_PIXELS {
262
+ return Err(ToolResponse::error(format!(
263
+ "Cannot render selected PDF pages: combined 150 DPI size exceeds the {MAX_CALL_PIXELS}-pixel safety limit. Select fewer pages."
264
+ )));
265
+ }
266
+ plans.push(RenderPlan {
267
+ number: *page_number,
268
+ width,
269
+ height,
270
+ });
271
+ }
272
+ Ok(plans)
273
+ }
274
+
275
+ fn collect_encoded_images(
276
+ plans: &[RenderPlan],
277
+ total_pages: usize,
278
+ payload_limit: usize,
279
+ mut encode: impl FnMut(&RenderPlan) -> Result<String, ToolResponse>,
280
+ ) -> ToolResponse {
281
+ let mut content = Vec::with_capacity(plans.len() + 1);
282
+ let mut payload_bytes = 0_usize;
283
+ for plan in plans {
284
+ let data = match encode(plan) {
285
+ Ok(data) => data,
286
+ Err(response) => return response,
287
+ };
288
+ if payload_bytes.saturating_add(data.len()) > payload_limit {
289
+ if content.is_empty() {
290
+ return ToolResponse::error(format!(
291
+ "Cannot return PDF page {} as an image: the encoded image exceeds the 8 MiB payload safety limit. Use pdf_mode=\"text\" for this page.",
292
+ plan.number
293
+ ));
294
+ }
295
+ break;
296
+ }
297
+ payload_bytes = payload_bytes.saturating_add(data.len());
298
+ content.push(ToolContent::Image {
299
+ data,
300
+ mime_type: "image/png".to_string(),
301
+ detail: Some(ImageDetail::High),
302
+ });
303
+ }
304
+ let delivered = content.len();
305
+ let first = plans[0].number;
306
+ let last = plans[delivered - 1].number;
307
+ content.push(ToolContent::Text(pdf_terminal_note(
308
+ first,
309
+ last,
310
+ total_pages,
311
+ PdfMode::Image,
312
+ delivered,
313
+ )));
314
+ ToolResponse {
315
+ content,
316
+ is_error: false,
317
+ }
318
+ }
319
+
320
+ fn encode_page_png(document: &PdfDocument<'_>, plan: &RenderPlan) -> Result<String, ToolResponse> {
321
+ let page = document
322
+ .pages()
323
+ .get((plan.number - 1) as i32)
324
+ .map_err(|_| corrupted_pdf())?;
325
+ let bitmap = page
326
+ .render_with_config(&PdfRenderConfig::new().set_target_size(plan.width, plan.height))
327
+ .map_err(|_| corrupted_pdf())?;
328
+ let image = bitmap.as_image().map_err(|_| corrupted_pdf())?;
329
+ let mut png = Cursor::new(Vec::new());
330
+ image
331
+ .write_to(&mut png, ImageFormat::Png)
332
+ .map_err(|_| corrupted_pdf())?;
333
+ Ok(base64::engine::general_purpose::STANDARD.encode(png.into_inner()))
334
+ }
335
+
336
+ fn pdf_terminal_note(
337
+ first: usize,
338
+ last: usize,
339
+ total: usize,
340
+ mode: PdfMode,
341
+ delivered: usize,
342
+ ) -> String {
343
+ let span = page_span(first, last);
344
+ let verb = match mode {
345
+ PdfMode::Text => "shown",
346
+ PdfMode::Image => "rendered",
347
+ };
348
+ if last == total {
349
+ format!("(Complete: {span} of {total} {verb}.)")
350
+ } else {
351
+ let next_start = last + 1;
352
+ let next_end = next_start
353
+ .saturating_add(delivered.saturating_sub(1))
354
+ .min(total);
355
+ let next = if next_start == next_end {
356
+ next_start.to_string()
357
+ } else {
358
+ format!("{next_start}-{next_end}")
359
+ };
360
+ format!("(Partial: {span} of {total} {verb}. Continue with pages=\"{next}\".)")
361
+ }
362
+ }
363
+
364
+ fn page_span(first: usize, last: usize) -> String {
365
+ if first == last {
366
+ format!("page {first}")
367
+ } else {
368
+ format!("pages {first}-{last}")
369
+ }
370
+ }
371
+
372
+ fn render_dimensions(
373
+ page_number: usize,
374
+ width_points: f32,
375
+ height_points: f32,
376
+ ) -> Result<(i32, i32, u64), ToolResponse> {
377
+ let width = f64::from(width_points) * f64::from(RENDER_DPI) / 72.0;
378
+ let height = f64::from(height_points) * f64::from(RENDER_DPI) / 72.0;
379
+ if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
380
+ return Err(ToolResponse::error(format!(
381
+ "Cannot render PDF page {page_number}: invalid page dimensions. Repair or regenerate the PDF externally."
382
+ )));
383
+ }
384
+ let width = width.round().max(1.0) as u64;
385
+ let height = height.round().max(1.0) as u64;
386
+ let pixels = width.saturating_mul(height);
387
+ if width > MAX_RENDER_DIMENSION || height > MAX_RENDER_DIMENSION || pixels > MAX_PAGE_PIXELS {
388
+ return Err(ToolResponse::error(format!(
389
+ "Cannot render PDF page {page_number}: dimensions {width}x{height} pixels at 150 DPI exceed the rendering safety limits (max {MAX_RENDER_DIMENSION} pixels per side and {MAX_PAGE_PIXELS} pixels per page). Reduce the page size externally."
390
+ )));
391
+ }
392
+ Ok((width as i32, height as i32, pixels))
393
+ }
394
+
395
+ fn parse_pages(
396
+ value: Option<&str>,
397
+ total_pages: usize,
398
+ mode: PdfMode,
399
+ ) -> Result<Vec<usize>, String> {
400
+ let Some(value) = value else {
401
+ return match mode {
402
+ PdfMode::Text if total_pages > DEFAULT_MAX_PAGES => Err(format!(
403
+ "This PDF has {total_pages} pages. Specify the pages parameter (e.g. \"1-10\"); max 20 pages per call."
404
+ )),
405
+ PdfMode::Text => Ok((1..=total_pages).collect()),
406
+ PdfMode::Image => Ok((1..=total_pages.min(DEFAULT_IMAGE_PAGES)).collect()),
407
+ };
408
+ };
409
+ let invalid = || {
410
+ format!(
411
+ "Invalid pages value \"{value}\". Use forms like \"3\", \"1-5\" (max 20 pages per call)."
412
+ )
413
+ };
414
+ let (start, end) = if let Some((start, end)) = value.split_once('-') {
415
+ if end.contains('-') {
416
+ return Err(invalid());
417
+ }
418
+ let start = start.parse::<usize>().map_err(|_| invalid())?;
419
+ let end = end.parse::<usize>().map_err(|_| invalid())?;
420
+ if start > end {
421
+ return Err(invalid());
422
+ }
423
+ (start, end)
424
+ } else {
425
+ let page = value.parse::<usize>().map_err(|_| invalid())?;
426
+ (page, page)
427
+ };
428
+ if start == 0 || end > total_pages {
429
+ return Err(format!(
430
+ "Page range \"{value}\" is out of bounds: this PDF has {total_pages} pages."
431
+ ));
432
+ }
433
+ if end - start + 1 > MAX_PAGES_PER_CALL {
434
+ return Err(invalid());
435
+ }
436
+ Ok((start..=end).collect())
437
+ }
438
+
439
+ fn pdf_load_error(error: PdfiumError) -> ToolResponse {
440
+ match error {
441
+ PdfiumError::PdfiumLibraryInternalError(
442
+ PdfiumInternalError::PasswordError | PdfiumInternalError::SecurityError,
443
+ ) => ToolResponse::error("Cannot read PDF: the file is password-protected."),
444
+ _ => ToolResponse::error("Cannot read PDF: the file is corrupted or not a valid PDF."),
445
+ }
446
+ }
447
+
448
+ fn corrupted_pdf() -> ToolResponse {
449
+ ToolResponse::error("Cannot read PDF: the file is corrupted or not a valid PDF.")
450
+ }
451
+
452
+ fn pdf_engine_error(reason: &str) -> ToolResponse {
453
+ ToolResponse::error(format!(
454
+ "PDF support is unavailable: could not load the bundled PDF engine ({reason}). Other file types are unaffected."
455
+ ))
456
+ }
457
+
458
+ fn budget_too_small(budget: TokenBudget) -> ToolResponse {
459
+ ToolResponse::error(format!(
460
+ "{}={} is too small to return the required continuation note. Increase it and retry.",
461
+ budget.variable, budget.value
462
+ ))
463
+ }
464
+
465
+ fn normalize_pdf_text(text: &str) -> String {
466
+ text.replace("\r\n", "\n")
467
+ .replace('\r', "\n")
468
+ .trim_end_matches('\n')
469
+ .to_string()
470
+ }
@@ -0,0 +1,47 @@
1
+ //! PDF request surface for builds compiled without the optional `pdf` feature.
2
+
3
+ use crate::budget::TokenBudget;
4
+ use crate::model::ToolResponse;
5
+ use schemars::JsonSchema;
6
+ use serde::Deserialize;
7
+ use std::path::Path;
8
+
9
+ /// PDF response channel retained in the public schema for no-PDF builds.
10
+ #[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Eq, PartialEq)]
11
+ #[serde(rename_all = "lowercase")]
12
+ pub(super) enum PdfMode {
13
+ /// Return the selected pages' text layer.
14
+ #[default]
15
+ Text,
16
+ /// Return rendered page images.
17
+ Image,
18
+ }
19
+
20
+ pub(super) fn is_pdf(path: &Path, bytes: &[u8]) -> bool {
21
+ bytes.starts_with(b"%PDF")
22
+ || path
23
+ .extension()
24
+ .and_then(|extension| extension.to_str())
25
+ .is_some_and(|extension| extension.eq_ignore_ascii_case("pdf"))
26
+ }
27
+
28
+ pub(super) fn parse_pdf_mode(value: Option<&str>) -> Result<PdfMode, String> {
29
+ match value {
30
+ None | Some("text") => Ok(PdfMode::Text),
31
+ Some("image") => Ok(PdfMode::Image),
32
+ Some(value) => Err(format!(
33
+ "Invalid pdf_mode value \"{value}\". Use \"text\" or \"image\"."
34
+ )),
35
+ }
36
+ }
37
+
38
+ pub(super) fn read_pdf(
39
+ _path: &Path,
40
+ _pages_value: Option<&str>,
41
+ _mode: PdfMode,
42
+ _text_budget: Option<TokenBudget>,
43
+ ) -> ToolResponse {
44
+ ToolResponse::error(
45
+ "PDF support is unavailable: could not load the bundled PDF engine (this binary was built without the pdf feature). Other file types are unaffected.",
46
+ )
47
+ }