1use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::html::{HtmlBlock, render_blocks_to_pages};
12use crate::error::{Error, Result};
13use crate::table::{TableAlign, TableData};
14
15const MAX_OPENSCAD_BYTES: u64 = 128 * 1024 * 1024;
16const MAX_OPENSCAD_LINES: usize = 2_000_000;
17const MAX_OPENSCAD_LINE_BYTES: usize = 1024 * 1024;
18const MAX_OPENSCAD_ROWS: usize = 100_000;
19const MAX_OPENSCAD_DISPLAY_BYTES: usize = 512;
20
21#[derive(Default)]
22struct Summary {
23 lines: usize,
24 modules: usize,
25 functions: usize,
26 primitives: usize,
27 transforms: usize,
28 booleans: usize,
29 imports: usize,
30 uses: usize,
31 includes: usize,
32 assignments: usize,
33 definitions: Vec<String>,
34 rows: Vec<Vec<String>>,
35}
36
37struct OpenScadPageSink<'a> {
38 inner: &'a mut dyn PageConsumer,
39 warnings: &'a [String],
40}
41
42impl PageConsumer for OpenScadPageSink<'_> {
43 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
44 page.source_format = "openscad".into();
45 if page.title.is_empty() {
46 page.title = "OpenSCAD source".into();
47 }
48 page.description = "OpenSCAD source was scanned as inert CAD code; geometry, expressions, imports and external files were not executed".into();
49 for warning in self.warnings {
50 page.warn(warning.clone());
51 }
52 self.inner.consume(page)
53 }
54}
55
56pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
57 let text = String::from_utf8_lossy(prefix);
58 let call = [
59 "cube(",
60 "sphere(",
61 "cylinder(",
62 "translate(",
63 "rotate(",
64 "linear_extrude(",
65 "import(",
66 ]
67 .iter()
68 .any(|needle| text.contains(needle));
69 let declaration = text.lines().any(|line| {
70 let line = line.trim_start();
71 ["module ", "function ", "use <", "include <"]
72 .iter()
73 .any(|needle| line.starts_with(needle))
74 });
75 (call || declaration) && (text.contains('{') || text.contains(';'))
76}
77
78pub(crate) fn convert(
79 path: &Path,
80 options: &ConvertOptions,
81 sink: &mut dyn PageConsumer,
82) -> Result<Vec<String>> {
83 let bytes = read_limited_file(
84 path,
85 options.max_input_bytes.min(MAX_OPENSCAD_BYTES),
86 "OpenSCAD input",
87 )?;
88 let source = std::str::from_utf8(&bytes)
89 .map_err(|error| Error::InvalidInput(format!("OpenSCAD input must be UTF-8: {error}")))?;
90 let mut summary = parse_source(source)?;
91 push_row(
92 &mut summary.rows,
93 "Source",
94 "OpenSCAD",
95 &format!(
96 "lines={} assignments={}",
97 summary.lines, summary.assignments
98 ),
99 )?;
100 push_row(
101 &mut summary.rows,
102 "Declarations",
103 &summary.modules.to_string(),
104 &format!(
105 "functions={} definitions={}",
106 summary.functions,
107 summary.definitions.len()
108 ),
109 )?;
110 push_row(
111 &mut summary.rows,
112 "Geometry",
113 &summary.primitives.to_string(),
114 &format!(
115 "transforms={} booleans={}",
116 summary.transforms, summary.booleans
117 ),
118 )?;
119 push_row(
120 &mut summary.rows,
121 "References",
122 &summary.imports.to_string(),
123 &format!("use={} include={}", summary.uses, summary.includes),
124 )?;
125 for definition in summary
126 .definitions
127 .iter()
128 .take(MAX_OPENSCAD_ROWS.saturating_sub(summary.rows.len()))
129 {
130 push_row(
131 &mut summary.rows,
132 "Definition",
133 definition,
134 "name only; body not executed",
135 )?;
136 }
137 let blocks = vec![
138 HtmlBlock::Heading {
139 level: 1,
140 text: "OpenSCAD source".into(),
141 },
142 HtmlBlock::Paragraph {
143 text: "OpenSCAD is a programmable CAD language. This preview reports inert source structure and never evaluates it.".into(),
144 },
145 HtmlBlock::Table(TableData {
146 headers: vec!["Kind".into(), "Value".into(), "Detail".into()],
147 rows: summary.rows,
148 alignments: vec![TableAlign::Left; 3],
149 raw_source: String::new(),
150 }),
151 ];
152 let warnings = vec![
153 "OpenSCAD modules, functions, expressions, loops and geometry were not executed; the preview is not a rendered solid model".into(),
154 "import(), use(), include(), surface() and file-like references were counted only; no referenced file, URL or library was opened".into(),
155 ];
156 let mut page_sink = OpenScadPageSink {
157 inner: sink,
158 warnings: &warnings,
159 };
160 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
161 Ok(warnings)
162}
163
164fn parse_source(source: &str) -> Result<Summary> {
165 let mut summary = Summary::default();
166 let mut block_comment = false;
167 for (line_index, raw_line) in source.lines().enumerate() {
168 if line_index >= MAX_OPENSCAD_LINES {
169 return Err(Error::LimitExceeded(format!(
170 "OpenSCAD lines exceed {MAX_OPENSCAD_LINES}"
171 )));
172 }
173 if raw_line.len() > MAX_OPENSCAD_LINE_BYTES {
174 return Err(Error::LimitExceeded(format!(
175 "OpenSCAD line exceeds {MAX_OPENSCAD_LINE_BYTES} bytes"
176 )));
177 }
178 summary.lines += 1;
179 let line = strip_comments(raw_line, &mut block_comment);
180 scan_line(&line, &mut summary);
181 }
182 if block_comment {
183 return Err(Error::InvalidInput(
184 "OpenSCAD source has an unterminated block comment".into(),
185 ));
186 }
187 if summary.modules == 0
188 && summary.functions == 0
189 && summary.primitives == 0
190 && summary.transforms == 0
191 && summary.imports == 0
192 && summary.uses == 0
193 && summary.includes == 0
194 {
195 return Err(Error::InvalidInput(
196 "OpenSCAD source contains no recognized language constructs".into(),
197 ));
198 }
199 Ok(summary)
200}
201
202fn strip_comments(line: &str, block_comment: &mut bool) -> String {
203 let mut output = String::with_capacity(line.len());
204 let bytes = line.as_bytes();
205 let mut index = 0;
206 while index < bytes.len() {
207 if *block_comment {
208 if index + 1 < bytes.len() && bytes[index] == b'*' && bytes[index + 1] == b'/' {
209 *block_comment = false;
210 index += 2;
211 } else {
212 index += 1;
213 }
214 } else if index + 1 < bytes.len() && bytes[index] == b'/' && bytes[index + 1] == b'/' {
215 break;
216 } else if index + 1 < bytes.len() && bytes[index] == b'/' && bytes[index + 1] == b'*' {
217 *block_comment = true;
218 index += 2;
219 } else {
220 output.push(bytes[index] as char);
221 index += 1;
222 }
223 }
224 output
225}
226
227fn scan_line(line: &str, summary: &mut Summary) {
228 for keyword in [
229 "cube",
230 "sphere",
231 "cylinder",
232 "polyhedron",
233 "polygon",
234 "square",
235 "circle",
236 "text",
237 "surface",
238 ] {
239 summary.primitives += occurrences_as_call(line, keyword);
240 }
241 for keyword in [
242 "linear_extrude",
243 "rotate_extrude",
244 "translate",
245 "rotate",
246 "scale",
247 "mirror",
248 "multmatrix",
249 "projection",
250 ] {
251 summary.transforms += occurrences_as_call(line, keyword);
252 }
253 for keyword in ["union", "difference", "intersection", "hull", "minkowski"] {
254 summary.booleans += occurrences_as_call(line, keyword);
255 }
256 summary.modules += occurrences_as_call(line, "module");
257 summary.functions += occurrences_as_call(line, "function");
258 summary.imports += occurrences_as_call(line, "import");
259 summary.uses += occurrences_as_call(line, "use");
260 summary.includes += occurrences_as_call(line, "include");
261 summary.assignments += line.matches('=').count();
262 for keyword in ["module", "function"] {
263 if occurrences_as_call(line, keyword) > 0
264 && let Some(name) = definition_name(line, keyword)
265 && summary.definitions.len() < MAX_OPENSCAD_ROWS
266 {
267 summary.definitions.push(name);
268 }
269 }
270}
271
272fn occurrences_as_call(line: &str, keyword: &str) -> usize {
273 let mut count = 0;
274 let mut offset = 0;
275 while let Some(found) = line[offset..].find(keyword) {
276 let start = offset + found;
277 let before = line[..start].chars().next_back();
278 let after = line[start + keyword.len()..].chars().next();
279 if before.is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_')
280 && after.is_some_and(|ch| ch == '(' || ch.is_ascii_whitespace())
281 {
282 count += 1;
283 }
284 offset = start + keyword.len();
285 }
286 count
287}
288
289fn definition_name(line: &str, keyword: &str) -> Option<String> {
290 let position = line.find(keyword)? + keyword.len();
291 let rest = line[position..].trim_start();
292 let end = rest.find(['(', '{', '='])?;
293 let name = rest[..end].trim();
294 (!name.is_empty()
295 && name
296 .chars()
297 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_'))
298 .then(|| truncate(name))
299}
300
301fn truncate(value: &str) -> String {
302 if value.len() <= MAX_OPENSCAD_DISPLAY_BYTES {
303 value.to_owned()
304 } else {
305 let mut end = MAX_OPENSCAD_DISPLAY_BYTES;
306 while !value.is_char_boundary(end) {
307 end -= 1;
308 }
309 format!("{}…", &value[..end])
310 }
311}
312
313fn push_row(rows: &mut Vec<Vec<String>>, kind: &str, value: &str, detail: &str) -> Result<()> {
314 if rows.len() >= MAX_OPENSCAD_ROWS {
315 return Err(Error::LimitExceeded(format!(
316 "OpenSCAD rows exceed {MAX_OPENSCAD_ROWS}"
317 )));
318 }
319 rows.push(vec![truncate(kind), truncate(value), truncate(detail)]);
320 Ok(())
321}