1#!/usr/bin/env python
2
3from __future__ import print_function
4
5import os
6import re
7import sys
8import math
9import random
10import os.path
11import argparse
12import subprocess
13
14
15
16def get_header_link(name):
17 refpat = re.compile("[^a-z0-9_ -]")
18 return refpat.sub("", name.lower()).replace(" ", "-")
19
20
21def toc_entry(name, indent, count=None):
22 lname = "{0}{1}".format(
23 ("%d. " % count) if count else "",
24 name
25 )
26 ref = get_header_link(lname)
27 if name.endswith( (")", "}", "]") ):
28 name = "`" + name.replace("\\", "") + "`"
29 return "{0}{1} [{2}](#{3})".format(
30 indent,
31 ("%d." % count) if count else "-",
32 name,
33 ref
34 )
35
36
37def mkdn_esc(txt):
38 out = ""
39 quotpat = re.compile(r'([^`]*)(`[^`]*`)(.*$)');
40 while txt:
41 m = quotpat.match(txt)
42 if m:
43 out += m.group(1).replace(r'_', r'\_')
44 out += m.group(2)
45 txt = m.group(3)
46 else:
47 out += txt.replace(r'_', r'\_')
48 txt = ""
49 return out
50
51
52def get_comment_block(lines, prefix, blanks=1):
53 out = []
54 blankcnt = 0
55 while lines:
56 if not lines[0].startswith(prefix + " "):
57 break
58 line = lines.pop(0).rstrip().lstrip("/")
59 if line == "":
60 blankcnt += 1
61 if blankcnt >= blanks:
62 break
63 else:
64 blankcnt = 0
65 line = line[len(prefix):]
66 out.append(line)
67 return (lines, out)
68
69
70class ImageProcessing(object):
71 def __init__(self):
72 self.examples = []
73 self.commoncode = []
74 self.imgroot = ""
75 self.keep_scripts = False
76
77 def set_keep_scripts(self, x):
78 self.keep_scripts = x
79
80 def add_image(self, libfile, imgfile, code, extype):
81 self.examples.append((libfile, imgfile, code, extype))
82
83 def set_commoncode(self, code):
84 self.commoncode = code
85
86 def process_examples(self, imgroot):
87 self.imgroot = imgroot
88 for libfile, imgfile, code, extype in self.examples:
89 self.gen_example_image(libfile, imgfile, code, extype)
90
91 def gen_example_image(self, libfile, imgfile, code, extype):
92 OPENSCAD = "/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD"
93 CONVERT = "/usr/local/bin/convert"
94 COMPARE = "/usr/local/bin/compare"
95
96 if extype == "NORENDER":
97 return
98
99 scriptfile = "tmp_{0}.scad".format(imgfile.replace(".", "_"))
100
101 stdlibs = ["constants.scad", "math.scad", "transforms.scad", "shapes.scad", "debug.scad"]
102 script = ""
103 for lib in stdlibs:
104 script += "include <BOSL/%s>\n" % lib
105 if libfile not in stdlibs:
106 script += "include <BOSL/%s>\n" % libfile
107 for line in self.commoncode:
108 script += line+"\n"
109 for line in code:
110 script += line+"\n"
111
112 with open(scriptfile, "w") as f:
113 f.write(script)
114
115 if "Med" in extype:
116 imgsizes = ["800,600", "400x300"]
117 elif "Big" in extype:
118 imgsizes = ["1280,960", "640x480"]
119 elif "distribute" in script:
120 print(script)
121 imgsizes = ["800,600", "400x300"]
122 else: # Small
123 imgsizes = ["480,360", "240x180"]
124
125 print("")
126 print("{}: {}".format(libfile, imgfile))
127
128 tmpimgs = []
129 if "Spin" in extype:
130 for ang in range(0,359,10):
131 tmpimgfile = "{0}tmp_{2}_{1}.png".format(self.imgroot, ang, imgfile.replace(".", "_"))
132 arad = ang * math.pi / 180;
133 eye = "{0},{1},{2}".format(
134 500*math.cos(arad),
135 500*math.sin(arad),
136 500 if "Flat" in extype else 500*math.sin(arad)
137 )
138 scadcmd = [
139 OPENSCAD,
140 "-o", tmpimgfile,
141 "--imgsize={}".format(imgsizes[0]),
142 "--hardwarnings",
143 "--projection=o",
144 "--view=axes,scales",
145 "--autocenter",
146 "--viewall",
147 "--camera", eye+",0,0,0"
148 ]
149 if "FR" in extype: # Force render
150 scadcmd.extend(["--render", ""])
151 scadcmd.append(scriptfile)
152 print(" ".join(scadcmd))
153 res = subprocess.call(scadcmd)
154 if res != 0:
155 print(script)
156 sys.exit(res)
157 tmpimgs.append(tmpimgfile)
158 else:
159 tmpimgfile = self.imgroot + "tmp_" + imgfile
160 scadcmd = [
161 OPENSCAD,
162 "-o", tmpimgfile,
163 "--imgsize={}".format(imgsizes[0]),
164 "--hardwarnings",
165 "--projection=o",
166 "--view=axes,scales",
167 "--autocenter",
168 "--viewall"
169 ]
170 if "2D" in extype: # 2D viewpoint
171 scadcmd.extend(["--camera", "0,0,0,0,0,0,500"])
172 if "FR" in extype: # Force render
173 scadcmd.extend(["--render", ""])
174 scadcmd.append(scriptfile)
175
176 print(" ".join(scadcmd))
177 res = subprocess.call(scadcmd)
178 if res != 0:
179 print(script)
180 sys.exit(res)
181 tmpimgs.append(tmpimgfile)
182
183 if not self.keep_scripts:
184 os.unlink(scriptfile)
185 targimgfile = self.imgroot + imgfile
186 newimgfile = self.imgroot + "_new_" + imgfile
187 if len(tmpimgs) == 1:
188 cnvcmd = [CONVERT, tmpimgfile, "-resize", imgsizes[1], newimgfile]
189 print(" ".join(cnvcmd))
190 res = subprocess.call(cnvcmd)
191 if res != 0:
192 sys.exit(res)
193 os.unlink(tmpimgs.pop(0))
194 else:
195 cnvcmd = [
196 CONVERT,
197 "-delay", "25",
198 "-loop", "0",
199 "-coalesce",
200 "-scale", imgsizes[1],
201 "-fuzz", "2%",
202 "+dither",
203 "-layers", "Optimize",
204 "+map"
205 ]
206 cnvcmd.extend(tmpimgs)
207 cnvcmd.append(newimgfile)
208 print(" ".join(cnvcmd))
209 res = subprocess.call(cnvcmd)
210 if res != 0:
211 sys.exit(res)
212 for tmpimg in tmpimgs:
213 os.unlink(tmpimg)
214
215 # Time to compare image.
216 if not os.path.isfile(targimgfile):
217 print("NEW IMAGE installed.")
218 os.rename(newimgfile, targimgfile)
219 else:
220 if targimgfile.endswith(".gif"):
221 cmpcmd = ["cmp", newimgfile, targimgfile]
222 print(" ".join(cmpcmd))
223 res = subprocess.call(cmpcmd)
224 issame = res == 0
225 else:
226 cmpcmd = [COMPARE, "-metric", "MAE", newimgfile, targimgfile, "null:"]
227 print(" ".join(cmpcmd))
228 p = subprocess.Popen(cmpcmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
229 issame = p.stdout.read().strip() == "0 (0)"
230 if issame:
231 print("Image unchanged.")
232 os.unlink(newimgfile)
233 else:
234 print("Image UPDATED.")
235 os.unlink(targimgfile)
236 os.rename(newimgfile, targimgfile)
237
238
239imgprc = ImageProcessing()
240
241
242class LeafNode(object):
243 def __init__(self):
244 self.name = ""
245 self.leaftype = ""
246 self.status = ""
247 self.description = []
248 self.usages = []
249 self.arguments = []
250 self.side_effects = []
251 self.examples = []
252
253 @classmethod
254 def match_line(cls, line, prefix):
255 if line.startswith(prefix + "Constant: "):
256 return True
257 if line.startswith(prefix + "Function: "):
258 return True
259 if line.startswith(prefix + "Module: "):
260 return True
261 return False
262
263 def add_example(self, title, code, extype):
264 self.examples.append((title, code, extype))
265
266 def parse_lines(self, lines, prefix):
267 blankcnt = 0
268 expat = re.compile(r"^(Examples?)(\(([^\)]*)\))?: *(.*)$")
269 while lines:
270 if prefix and not lines[0].startswith(prefix.strip()):
271 break
272 line = lines.pop(0).rstrip()
273 if line.lstrip("/").strip() == "":
274 blankcnt += 1
275 if blankcnt >= 2:
276 break
277 continue
278 blankcnt = 0
279 line = line[len(prefix):]
280 if line.startswith("Constant:"):
281 leaftype, title = line.split(":", 1)
282 self.name = title.strip()
283 self.leaftype = leaftype.strip()
284 if line.startswith("Function:"):
285 leaftype, title = line.split(":", 1)
286 self.name = title.strip()
287 self.leaftype = leaftype.strip()
288 if line.startswith("Module:"):
289 leaftype, title = line.split(":", 1)
290 self.name = title.strip()
291 self.leaftype = leaftype.strip()
292 if line.startswith("Status:"):
293 dummy, status = line.split(":", 1)
294 self.status = status.strip()
295 if line.startswith("Description:"):
296 dummy, desc = line.split(":", 1)
297 desc = desc.strip()
298 if desc:
299 self.description.append(desc)
300 lines, block = get_comment_block(lines, prefix)
301 self.description.extend(block)
302 if line.startswith("Usage:"):
303 dummy, title = line.split(":", 1)
304 title = title.strip()
305 lines, block = get_comment_block(lines, prefix)
306 self.usages.append([title, block])
307 if line.startswith("Arguments:"):
308 lines, block = get_comment_block(lines, prefix)
309 for line in block:
310 if "=" not in line:
311 print("Error: bad argument line:")
312 print(line)
313 sys.exit(2)
314 argname, argdesc = line.split("=", 1)
315 argname = argname.strip()
316 argdesc = argdesc.strip()
317 self.arguments.append([argname, argdesc])
318 if line.startswith("Side Effects:"):
319 lines, block = get_comment_block(lines, prefix)
320 self.side_effects.extend(block)
321 m = expat.match(line)
322 if m: # Example(TYPE):
323 plural = m.group(1) == "Examples"
324 extype = m.group(3)
325 title = m.group(4)
326 lines, block = get_comment_block(lines, prefix)
327 if not extype:
328 extype = "3D" if self.leaftype == "Module" else "NORENDER"
329 if not plural:
330 self.add_example(title=title, code=block, extype=extype)
331 else:
332 for line in block:
333 self.add_example(title="", code=[line], extype=extype)
334 return lines
335
336 def gen_md(self, fileroot, imgroot):
337 out = []
338 if self.name:
339 out.append("### " + mkdn_esc(self.name))
340 out.append("")
341 if self.status:
342 out.append("**{0}**".format(mkdn_esc(self.status)))
343 out.append("")
344 for title, usages in self.usages:
345 if not title:
346 title = "Usage"
347 out.append("**{0}**:".format(mkdn_esc(title)))
348 for usage in usages:
349 out.append("- {0}".format(mkdn_esc(usage)))
350 out.append("")
351 if self.description:
352 out.append("**Description**:")
353 for line in self.description:
354 out.append(mkdn_esc(line))
355 out.append("")
356 if self.arguments:
357 out.append("Argument | What it does")
358 out.append("--------------- | ------------------------------")
359 for argname, argdesc in self.arguments:
360 argname = argname.replace(" / ", "` / `")
361 out.append(
362 "{0:15s} | {1}".format(
363 "`{0}`".format(argname),
364 mkdn_esc(argdesc)
365 )
366 )
367 out.append("")
368 if self.side_effects:
369 out.append("**Side Effects**:")
370 for sfx in self.side_effects:
371 out.append("- " + mkdn_esc(sfx))
372 out.append("")
373 exnum = 0
374 for title, excode, extype in self.examples:
375 exnum += 1
376 if len(self.examples) < 2:
377 extitle = "**Example**:"
378 else:
379 extitle = "**Example {0}**:".format(exnum)
380 if title:
381 extitle += " " + mkdn_esc(title)
382 out.append(extitle)
383 out.append("")
384 for line in excode:
385 out.append(" " + line)
386 out.append("")
387 san_name = re.sub(r"[^A-Za-z0-9_]", "", self.name)
388 imgfile = "{0}{1}.{2}".format(
389 san_name,
390 ("_%d" % exnum) if exnum > 1 else "",
391 "gif" if "Spin" in extype else "png"
392 )
393 if extype != "NORENDER":
394 out.append(
395 "".format(
396 mkdn_esc(self.name),
397 (" %d" % exnum) if len(self.examples) > 1 else "",
398 imgroot,
399 imgfile
400 )
401 )
402 out.append("")
403 imgprc.add_image(fileroot+".scad", imgfile, excode, extype)
404 out.append("---")
405 out.append("")
406 return out
407
408
409class Section(object):
410 fignum = 0
411 def __init__(self):
412 self.name = ""
413 self.description = []
414 self.leaf_nodes = []
415 self.figures = []
416
417 @classmethod
418 def match_line(cls, line, prefix):
419 if line.startswith(prefix + "Section: "):
420 return True
421 return False
422
423 def add_figure(self, figtitle, figcode, figtype):
424 self.figures.append((figtitle, figcode, figtype))
425
426 def parse_lines(self, lines, prefix):
427 line = lines.pop(0).rstrip()
428 dummy, title = line.split(": ", 1)
429 self.name = title.strip()
430 lines, block = get_comment_block(lines, prefix, blanks=2)
431 self.description.extend(block)
432 blankcnt = 0
433 figpat = re.compile(r"^(Figures?)(\(([^\)]*)\))?: *(.*)$")
434 while lines:
435 if prefix and not lines[0].startswith(prefix.strip()):
436 break
437 line = lines.pop(0).rstrip()
438 if line.lstrip("/").strip() == "":
439 blankcnt += 1
440 if blankcnt >= 2:
441 break
442 continue
443 blankcnt = 0
444 line = line[len(prefix):]
445 m = figpat.match(line)
446 if m: # Figures(TYPE):
447 plural = m.group(1) == "Figures"
448 figtype = m.group(3)
449 title = m.group(4)
450 lines, block = get_comment_block(lines, prefix)
451 if not figtype:
452 figtype = "3D" if self.figtype == "Module" else "NORENDER"
453 if not plural:
454 self.add_figure(title, block, figtype)
455 else:
456 for line in block:
457 self.add_figure("", [line], figtype)
458 return lines
459
460 def gen_md_toc(self, count):
461 indent=""
462 out = []
463 if self.name:
464 out.append(toc_entry(self.name, indent, count=count))
465 indent += " "
466 for node in self.leaf_nodes:
467 out.append(toc_entry(node.name, indent))
468 out.append("")
469 return out
470
471 def gen_md(self, count, fileroot, imgroot):
472 out = []
473 if self.name:
474 out.append("# %d. %s" % (count, mkdn_esc(self.name)))
475 out.append("")
476 if self.description:
477 in_block = False
478 for line in self.description:
479 if line.startswith("```"):
480 in_block = not in_block
481 if in_block or line.startswith(" "):
482 out.append(line)
483 else:
484 out.append(mkdn_esc(line))
485 out.append("")
486 for title, figcode, figtype in self.figures:
487 Section.fignum += 1
488 figtitle = "**Figure {0}**:".format(Section.fignum)
489 if title:
490 figtitle += " " + mkdn_esc(title)
491 out.append(figtitle)
492 out.append("")
493 imgfile = "{}{}.{}".format(
494 "figure",
495 Section.fignum,
496 "gif" if "Spin" in figtype else "png"
497 )
498 if figtype != "NORENDER":
499 out.append(
500 "".format(
501 mkdn_esc(self.name),
502 Section.fignum,
503 imgroot,
504 imgfile
505 )
506 )
507 out.append("")
508 imgprc.add_image(fileroot+".scad", imgfile, figcode, figtype)
509 in_block = False
510 for node in self.leaf_nodes:
511 out += node.gen_md(fileroot, imgroot)
512 return out
513
514
515class LibFile(object):
516 def __init__(self):
517 self.name = ""
518 self.description = []
519 self.commoncode = []
520 self.sections = []
521 self.dep_sect = None
522
523 def parse_lines(self, lines, prefix):
524 currsect = None
525 constpat = re.compile(r"^([A-Z_0-9][A-Z_0-9]*) *=.* // (.*$)")
526 while lines:
527 while lines and prefix and not lines[0].startswith(prefix.strip()):
528 line = lines.pop(0)
529 m = constpat.match(line)
530 if m:
531 if currsect == None:
532 currsect = Section()
533 self.sections.append(currsect)
534 node = LeafNode();
535 node.extype = "Constant"
536 node.name = m.group(1).strip()
537 node.description.append(m.group(2).strip())
538 currsect.leaf_nodes.append(node)
539
540 # Check for LibFile header.
541 if lines and lines[0].startswith(prefix + "LibFile: "):
542 line = lines.pop(0).rstrip()
543 dummy, title = line.split(": ", 1)
544 self.name = title.strip()
545 lines, block = get_comment_block(lines, prefix, blanks=2)
546 self.description.extend(block)
547
548 # Check for CommonCode header.
549 if lines and lines[0].startswith(prefix + "CommonCode:"):
550 lines.pop(0)
551 lines, block = get_comment_block(lines, prefix)
552 self.commoncode.extend(block)
553
554 # Check for Section header.
555 if lines and Section.match_line(lines[0], prefix):
556 sect = Section()
557 lines = sect.parse_lines(lines, prefix)
558 self.sections.append(sect)
559 currsect = sect
560
561 # Check for LeafNode.
562 if lines and LeafNode.match_line(lines[0], prefix):
563 node = LeafNode()
564 lines = node.parse_lines(lines, prefix)
565 deprecated = node.status.startswith("DEPRECATED")
566 if deprecated:
567 if self.dep_sect == None:
568 self.dep_sect = Section()
569 self.dep_sect.name = "Deprecations"
570 sect = self.dep_sect
571 else:
572 if currsect == None:
573 currsect = Section()
574 self.sections.append(currsect)
575 sect = currsect
576 sect.leaf_nodes.append(node)
577 if lines:
578 lines.pop(0)
579 return lines
580
581 def gen_md(self, fileroot, imgroot):
582 imgprc.set_commoncode(self.commoncode)
583 out = []
584 if self.name:
585 out.append("# Library File " + mkdn_esc(self.name))
586 out.append("")
587 if self.description:
588 in_block = False
589 for line in self.description:
590 if line.startswith("```"):
591 in_block = not in_block
592 if in_block or line.startswith(" "):
593 out.append(line)
594 else:
595 out.append(mkdn_esc(line))
596 out.append("")
597 in_block = False
598 if self.name or self.description:
599 out.append("---")
600 out.append("")
601
602 if self.sections or self.dep_sect:
603 out.append("# Table of Contents")
604 out.append("")
605 cnt = 0
606 for sect in self.sections:
607 cnt += 1
608 out += sect.gen_md_toc(cnt)
609 if self.dep_sect:
610 cnt += 1
611 out += self.dep_sect.gen_md_toc(cnt)
612 out.append("---")
613 out.append("")
614
615 cnt = 0
616 for sect in self.sections:
617 cnt += 1
618 out += sect.gen_md(cnt, fileroot, imgroot)
619 if self.dep_sect:
620 cnt += 1
621 out += self.dep_sect.gen_md(cnt, fileroot, imgroot)
622 return out
623
624
625def processFile(infile, outfile=None, gen_imgs=False, imgroot="", prefix=""):
626 if imgroot and not imgroot.endswith('/'):
627 imgroot += "/"
628
629 libfile = LibFile()
630 with open(infile, "r") as f:
631 lines = f.readlines()
632 libfile.parse_lines(lines, prefix)
633
634 if outfile == None:
635 f = sys.stdout
636 else:
637 f = open(outfile, "w")
638
639 fileroot = os.path.splitext(os.path.basename(infile))[0]
640 outdata = libfile.gen_md(fileroot, imgroot)
641 for line in outdata:
642 print(line, file=f)
643
644 if gen_imgs:
645 imgprc.process_examples(imgroot)
646
647 if outfile:
648 f.close()
649
650
651def main():
652 parser = argparse.ArgumentParser(prog='docs_gen')
653 parser.add_argument('-k', '--keep-scripts', action="store_true",
654 help="If given, don't delete the temporary image OpenSCAD scripts.")
655 parser.add_argument('-c', '--comments-only', action="store_true",
656 help='If given, only process lines that start with // comments.')
657 parser.add_argument('-i', '--images', action="store_true",
658 help='If given, generate images for examples with OpenSCAD.')
659 parser.add_argument('-I', '--imgroot', default="",
660 help='The directory to put generated images in.')
661 parser.add_argument('-o', '--outfile',
662 help='Output file, if different from infile.')
663 parser.add_argument('infile', help='Input filename.')
664 args = parser.parse_args()
665
666 imgprc.set_keep_scripts(args.keep_scripts)
667 processFile(
668 args.infile,
669 outfile=args.outfile,
670 gen_imgs=args.images,
671 imgroot=args.imgroot,
672 prefix="// " if args.comments_only else ""
673 )
674
675 sys.exit(0)
676
677
678if __name__ == "__main__":
679 main()
680
681
682# vim: expandtab tabstop=4 shiftwidth=4 softtabstop=4 nowrap