Skip to main content

document_svg/document/
openscad.rs

1//! Bounded OpenSCAD source previews.
2//!
3//! OpenSCAD is an executable CAD language. This adapter deliberately treats
4//! OpenSCAD files as inert source: it counts declarations, primitives,
5//! transforms and file-reference statements without evaluating expressions or
6//! opening files.
7
8use 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}