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                    "![{0} Example{1}]({2}{3})".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                    "![{0} Figure {1}]({2}{3})".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