unless window? path = require 'path' fs = require 'fs' xmldom = require '@xmldom/xmldom' DOMParser = xmldom.DOMParser domImplementation = new xmldom.DOMImplementation() XMLSerializer = xmldom.XMLSerializer prettyXML = require 'prettify-xml' graphemeSplitter = new require('grapheme-splitter')() metadata = require '../package.json' try metadata.modified = (fs.statSync __filename).mtimeMs else DOMParser = window.DOMParser # escape CoffeeScript scope domImplementation = document.implementation XMLSerializer = window.XMLSerializer # escape CoffeeScript scope path = basename: (x) -> /[^/]*$/.exec(x)[0] extname: (x) -> /\.[^/]+$/.exec(x)[0] dirname: (x) -> /[^]*\/|/.exec(x)[0] graphemeSplitter = splitGraphemes: (x) -> x.split '' metadata = version: '(web)' ## Combine a directory and filename, handling more cases than path module. ## Like path.join, keep using relative filenames if both are relative. ## Like path.resolve, correctly handle absolute filenames (ignore dirname). ## Also allow for null dirname (ignored) or null filename (returned). resolve = (dirname, filename) => if not dirname? or not filename? or path.isAbsolute filename filename else path.join dirname, filename ## Register `require` hooks of Babel and CoffeeScript, ## so that imported/required modules are similarly processed. unless window? ### Babel plugin to add implicit `export default` to last line of program, to simulate the effect of `eval` but in a module context. Only added if there isn't already an `export default` or `exports.default` in the code, and when the last line is an object, array, or function expression (with the idea that it wouldn't do much by itself). ### implicitFinalExportDefault = ({types}) -> visitor: Program: (path) -> body = path.get 'body' return unless body.length # empty program ## Check for existing `export default` or `exports.default` or ## `exports['default']`, in which case definitely don't add one. exportedDefault = false path.traverse( ExportDefaultDeclaration: (path) -> exportedDefault = true return MemberExpression: (path) -> {node} = path check = (key, value) -> types.isIdentifier(node.object) and node.object.name == key and ( (types.isIdentifier(node.property) and node.property.name == value) or (types.isStringLiteral(node.property) and node.property.value == value) ) exportedDefault or= check('exports', 'default') or check('module', 'exports') return ) return if exportedDefault last = body[body.length-1] lastNode = last.node if types.isExpressionStatement(last) and ( types.isObjectExpression(lastNode.expression) or types.isFunctionExpression(lastNode.expression) or types.isArrowFunctionExpression(lastNode.expression) or types.isArrayExpression(lastNode.expression) # not AssignmentExpression or CallExpression ) exportLast = types.exportDefaultDeclaration lastNode.expression exportLast.leadingComments = lastNode.leadingComments exportLast.innerComments = lastNode.innerComments exportLast.trailingComments = lastNode.trailingComments last.replaceWith exportLast return ## Modify `svgtiler.require` calls to add __dirname as third argument, ## so that files can be located relative to the module's directory. svgtilerRequire = ({types}) -> visitor: CallExpression: (path) -> {node} = path {callee} = node return unless types.isMemberExpression callee return unless types.isIdentifier(callee.object) and callee.object.name == 'svgtiler' return unless types.isIdentifier(callee.property) and callee.property.name == 'require' while node.arguments.length < 2 node.arguments.push types.identifier 'undefined' if node.arguments.length == 2 node.arguments.push types.identifier '__dirname' return verboseBabel = ({types}) -> post: (state) -> return unless getSettings()?.verbose if state.opts?.filename? filename = "[#{state.opts.filename}]" else if state.inputMap?.sourcemap?.sources?.length filename = "[converted from #{state.inputMap.sourcemap.sources.join ' & '}]" else filename = '' console.log '# Babel conversion input:', filename console.log state.code console.log '# Babel conversion output:', filename console.log \ require('@babel/generator').default(state.ast, babelConfig).code console.log '# End of Babel conversion', filename return babelConfig = plugins: [ implicitFinalExportDefault svgtilerRequire [require.resolve('babel-plugin-auto-import'), declarations: [ default: 'preact' path: 'preact' , default: 'svgtiler' members: ['share'] path: 'svgtiler' ] ] require.resolve '@babel/plugin-transform-modules-commonjs' [require.resolve('@babel/plugin-transform-react-jsx'), useBuiltIns: true runtime: 'automatic' importSource: 'preact' #pragma: 'preact.h' #pragmaFrag: 'preact.Fragment' throwIfNamespace: false ] require.resolve 'babel-plugin-module-deps' verboseBabel ] #inputSourceMap: true # CoffeeScript sets this to its own source map sourceMaps: 'inline' retainLines: true ## Tell CoffeeScript's register to transpile with our Babel config. module.options = bare: true # needed for implicitFinalExportDefault #inlineMap: true # rely on Babel's source map transpile: babelConfig ## Prevent Babel from caching its results, for changes to our plugins. require('@babel/register') {...babelConfig, cache: false} CoffeeScript = require 'coffeescript' CoffeeScript.FILE_EXTENSIONS = ['.coffee', '.cjsx'] CoffeeScript.register() defaultSettings = ## Log otherwise invisible actions to aid with debugging. ## Currently, 0 = none, nonzero = all, but there may be levels in future. verbose: 0 ## Force all tiles to have specified width or height. forceWidth: null ## default: no size forcing forceHeight: null ## default: no size forcing ## Inline s into output SVG (replacing URLs to other files). inlineImages: not window? ## Process hidden sheets within spreadsheet files. keepHidden: false ## Don't delete blank extreme rows/columns. keepMargins: false ## Don't make all rows have the same number of columns by padding with ## empty strings. keepUneven: false ## Array of conversion formats such as 'pdf' or 'pdf'. formats: null ## Override for output file's stem (basename without extension). ## Can use `*` to refer to input file's stem, to add prefix or suffix. outputStem: null ## Directories to output all or some files. ## Can also include stem overrides like "prefix_*_suffix". outputDir: null ## default: same directory as input outputDirExt: ## by extension; default is to use outputDir '.svg': null '.pdf': null '.png': null '.svg_tex': null ## Delete output files instead of creating them, like `make clean`. clean: false ## Path to inkscape. Default searches PATH. inkscape: 'inkscape' ## Default overflow behavior is 'visible' unless --no-overflow specified; ## use `overflow:hidden` to restore normal SVG behavior of keeping each tile ## within its bounding box. overflowDefault: 'visible' ## When a mapping refers to an SVG filename, assume this encoding. svgEncoding: 'utf8' ## Move from SVG to accompanying LaTeX file.tex. texText: false ## Use `href` instead of `xlink:href` attribute in and . ## `href` behaves better in web browsers, but `xlink:href` is more ## compatible with older SVG drawing programs. useHref: window? ## Wrap tags inside . Workaround for a bug in Inkscape, ## which duplicates s inside of s unless the inner symbols ## are wrapped in s. So use this when nesting, until bug is fixed. ## https://gitlab.com/inkscape/inbox/-/issues/12642 useDefs: false ## Add `data-key`/`data-i`/`data-j`/`data-k` attributes to elements, ## which specify the drawing key and location (row i, column j, layer k). useData: window? ## Background rectangle fill color. background: null ## Glob pattern for Maketiles. maketile: '[mM]aketile.{args,coffee,js}' ## renderDOM-specific filename: 'drawing.asc' # default filename when not otherwise specified keepParent: false keepClass: false ## Major state mappings: null # should be valid argument to new Mappings styles: null # should be valid argument to new Styles cloneSettings = (settings, addArrays) -> settings = {...settings} if settings.formats? settings.formats = [...settings.formats] else if addArrays settings.formats = [] if settings.mappings? settings.mappings = new Mappings settings.mappings else if addArrays settings.mappings = new Mappings if settings.styles? settings.styles = new Styles settings.styles else if addArrays settings.styles = new Styles settings getSetting = (settings, key) -> settings?[key] ? defaultSettings[key] getOutputDir = (settings, extension) -> dir = getSetting(settings, 'outputDirExt')?[extension] ? getSetting settings, 'outputDir' if dir try fs.mkdirSync dir, recursive: true catch err console.warn "Failed to make directory '#{dir}': #{err}" dir class HasSettings getSetting: (key) -> getSetting @settings, key getOutputDir: (extension) -> getOutputDir @settings, extension globalShare = {} # for shared data between mapping modules SVGNS = 'http://www.w3.org/2000/svg' XLINKNS = 'http://www.w3.org/1999/xlink' splitIntoLines = (data) -> data .replace /\r\n/g, '\n' # Windows EOL -> \n .replace /\r/g, '\n' # Mac EOL -> \n .replace /\n$/, '' # ignore newline on last line .split '\n' whitespace = /[\s\uFEFF\xA0]+/ ## based on https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim extensionOf = (filename) -> path.extname(filename).toLowerCase() class SVGTilerError extends Error constructor: (message) -> super message @name = 'SVGTilerError' parseNum = (x) -> parsed = parseFloat x if isNaN parsed null else parsed ## Allow Infinity, \infty, Inf with optional sign prefix infinityRegExp = /^\s*([+-]?)\\?infi?n?i?t?y?\s*$/i parseNumOrInfinity = (x) -> if (match = infinityRegExp.exec x)? if match[1] == '-' -Infinity else Infinity else parseNum x ## Conversion from arbitrary unit to px (SVG units), ## from https://www.w3.org/TR/css-values-3/#absolute-lengths units = cm: 96 / 2.54 mm: 96 / 25.4 Q: 96 / 25.4 / 4 in: 96 pc: 96 / 6 pt: 96 / 72 px: 1 undefined: 1 parseDim = (x) -> match = /^\s*([0-9.]+)\s*([a-zA-Z]\w+)?\s*$/.exec x return null unless match? if units.hasOwnProperty match[2] parseNum(match[1]) * units[match[2]] else console.warn "Unrecognized unit #{match[2]}" parseNum match[1] parseBox = (box, allowNull) -> return null unless box box = box.split /\s*[\s,]\s*/ .map parseNum return null if null in box unless allowNull box extractBoundingBox = (xml) -> ### Parse and return root `boundingBox` attribute, possibly under the old name of `overflowBox`. Also remove them if present, so output is valid SVG. ### box = xml.getAttribute('boundingBox') or xml.getAttribute('overflowBox') xml.removeAttribute 'boundingBox' xml.removeAttribute 'overflowBox' if box?.toLowerCase().trim() == 'none' [null, null, null, null] else parseBox box, true svgBBox = (dom) -> ## xxx Many unsupported features! ## - transformations ## - used symbols/defs ## - paths ## - text ## - line widths which extend bounding box recurse = (node) -> if node.nodeType != node.ELEMENT_NODE or node.nodeName in ['defs', 'use'] return null # Ignore s except the root that we're bounding if node.nodeName == 'symbol' and node != dom return null switch node.tagName when 'rect', 'image' ## For , should autodetect image size (#42) [parseNum(node.getAttribute 'x') ? 0 parseNum(node.getAttribute 'y') ? 0 parseNum(node.getAttribute 'width') ? 0 parseNum(node.getAttribute 'height') ? 0] when 'circle' cx = parseNum(node.getAttribute 'cx') ? 0 cy = parseNum(node.getAttribute 'cy') ? 0 r = parseNum(node.getAttribute 'r') ? 0 [cx - r, cy - r, 2*r, 2*r] when 'ellipse' cx = parseNum(node.getAttribute 'cx') ? 0 cy = parseNum(node.getAttribute 'cy') ? 0 rx = parseNum(node.getAttribute 'rx') ? 0 ry = parseNum(node.getAttribute 'ry') ? 0 [cx - rx, cy - ry, 2*rx, 2*ry] when 'line' x1 = parseNum(node.getAttribute 'x1') ? 0 y1 = parseNum(node.getAttribute 'y1') ? 0 x2 = parseNum(node.getAttribute 'x2') ? 0 y2 = parseNum(node.getAttribute 'y2') ? 0 xMin = Math.min x1, x2 yMin = Math.min y1, y2 [xMin, yMin, Math.max(x1, x2) - xMin, Math.max(y1, y2) - yMin] when 'polyline', 'polygon' points = for point in node.getAttribute('points').trim().split /\s+/ for coord in point.split /,/ parseFloat coord xs = (point[0] for point in points) ys = (point[1] for point in points) xMin = Math.min ...xs yMin = Math.min ...ys if isNaN(xMin) or isNaN(yMin) # invalid points attribute; don't render null else [xMin, yMin, Math.max(...xs) - xMin, Math.max(...ys) - yMin] else viewBoxes = (recurse(child) for child in node.childNodes) viewBoxes = (viewBox for viewBox in viewBoxes when viewBox?) xMin = Math.min ...(viewBox[0] for viewBox in viewBoxes) yMin = Math.min ...(viewBox[1] for viewBox in viewBoxes) xMax = Math.max ...(viewBox[0]+viewBox[2] for viewBox in viewBoxes) yMax = Math.max ...(viewBox[1]+viewBox[3] for viewBox in viewBoxes) [xMin, yMin, xMax - xMin, yMax - yMin] viewBox = recurse dom if not viewBox? or Infinity in viewBox or -Infinity in viewBox null else viewBox isAuto = (value) -> typeof value == 'string' and /^\s*auto\s*$/i.test value attributeOrStyle = (node, attr, styleKey = attr) -> if value = node.getAttribute attr value.trim() else style = node.getAttribute 'style' if style match = ///(?:^|;)\s*#{styleKey}\s*:\s*([^;\s][^;]*)///i.exec style match?[1] removeAttributeOrStyle = (node, attr, styleKey = attr) -> node.removeAttribute attr style = node.getAttribute 'style' return unless style? newStyle = style.replace ///(?:^|;)\s*#{styleKey}\s*:\s*([^;\s][^;]*)///i, '' if style != newStyle if newStyle.trim() node.setAttribute 'style', newStyle else node.removeAttribute 'style' getHref = (node) -> for key in ['xlink:href', 'href'] if href = node.getAttribute key return key: key href: href key: null href: null extractZIndex = (node) -> ## Check whether DOM node has a specified z-index, defaulting to zero. ## Special values of Infinity or -Infinity are allowed. ## Also remove z-index attribute, so output is valid SVG. ## Note that z-index must be an integer. ## 1. https://www.w3.org/Graphics/SVG/WG/wiki/Proposals/z-index suggests ## a z-index="..." attribute. Check for this first. ## 2. Look for style="z-index:..." as in HTML. z = parseNumOrInfinity attributeOrStyle node, 'z-index' removeAttributeOrStyle node, 'z-index' z ? 0 domRecurse = (node, callback) -> ### Recurse through DOM starting at `node`, calling `callback(node)` on every recursive node, including `node` itself. `callback()` should return a true value if you want to recurse into the specified node's children (typically, when there isn't a match). Robust against node being replaced. ### return unless callback node return unless node.hasChildNodes() child = node.lastChild while child? nextChild = child.previousSibling domRecurse child, callback child = nextChild return refRegExp = /// # url() without quotes \b url \s* \( \s* \# ([^()]*) \) # url() with quotes, or src() which requires quotes | \b (?: url | src) \s* \( \s* (['"]) \s* \# (.*?) \2 \s* \) ///g findRefs = (root) => ## Returns an array of id-based references to other elements in the SVG. refs = [] domRecurse root, (node) => return unless node.attributes? for attr in node.attributes if attr.name in ['href', 'xlink:href'] and (value = attr.value.trim()).startsWith '#' refs.push {id: value[1..].trim(), node, attr: attr.name} else while (match = refRegExp.exec attr.value)? refs.push {id: (match[1] or match[3]).trim(), node, attr: attr.name} true refs contentType = '.png': 'image/png' '.jpg': 'image/jpeg' '.jpeg': 'image/jpeg' '.gif': 'image/gif' '.svg': 'image/svg+xml' ## Support for `require`/`import`ing images. ## SVG files get parsed into Preact Virtual DOM so you can manipulate them, ## while raster images get converted into Preact Virtual DOM elements. ## In either case, DOM gets `svg` attribute with raw SVG string. unless window? pirates = require 'pirates' pirates.settings = defaultSettings pirates.addHook (code, filename) -> if '.svg' == extensionOf filename raw = code code = removeSVGComments code code = prefixSVGIds code, prefixForFilename filename domCode = require('@babel/core').transform "module.exports = #{code}", {...babelConfig, filename} """ #{domCode.code} module.exports.svg = #{JSON.stringify code}; module.exports.raw = #{JSON.stringify raw}; """ else href = hrefAttr pirates.settings """ module.exports = require('preact').h('image', #{JSON.stringify "#{href}": filename}); module.exports.svg = ''; """ , exts: Object.keys contentType isPreact = (data) -> typeof data == 'object' and data?.type? and data.props? $static = Symbol 'svgtiler.static' wrapStatic = (x) -> [$static]: x # exported as `static` but that's reserved #fileCache = new Map loadSVG = (filename, settings) -> #if (found = fileCache.get filename)? # return found #data = fs.readFileSync filename, encoding: getSetting settings, 'svgEncoding' ## TODO: Handle or BOM to override svgEncoding. #fileCache.set filename, data #data escapeId = (key) -> ### According to XML spec [https://www.w3.org/TR/xml/#id], id/href follows the XML name spec: [https://www.w3.org/TR/xml/#NT-Name] NameStartChar ::= ":" | [A-Z] | "_" | [a-z] | [#xC0-#xD6] | [#xD8-#xF6] | [#xF8-#x2FF] | [#x370-#x37D] | [#x37F-#x1FFF] | [#x200C-#x200D] | [#x2070-#x218F] | [#x2C00-#x2FEF] | [#x3001-#xD7FF] | [#xF900-#xFDCF] | [#xFDF0-#xFFFD] | [#x10000-#xEFFFF] NameChar ::= NameStartChar | "-" | "." | [0-9] | #xB7 | [#x0300-#x036F] | [#x203F-#x2040] Name ::= NameStartChar (NameChar)* In addition, colons in IDs fail when embedding an SVG via . We use encodeURIComponent which escapes everything except A-Z a-z 0-9 - _ . ! ~ * ' ( ) [https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent] into %-encoded symbols, plus we encode _ . ! ~ * ' ( ) and - 0-9 (start only). But % (and %-encoded) symbols are not supported, so we replace '%' with '_', an allowed character that we escape. In the special case of a blank key, we use the special _blank which cannot be generated by the escaping process. ### (encodeURIComponent key .replace /[_\.!~*'()]|^[\-0-9]/g, (c) -> "%#{c.charCodeAt(0).toString(16).toUpperCase()}" .replace /%/g, '_' ) or '_blank' zeroSizeReplacement = 1 removeSVGComments = (svg) -> ## Remove SVG/XML comments such as and ## (spec: https://www.w3.org/TR/2008/REC-xml-20081126/#NT-prolog) svg.replace /<\?[^]*?\?>||/g, '' prefixSVGIds = (svg, prefix) -> ## Prefix all id, href, xlink:href for scoping external SVG idMap = new Map svg = svg.replace /(? if idMap.has id console.warn "SVG #{prefix} has duplicate id: #{id}" else idMap.set id, "#{prefix}_#{id}" "#{pre}#{quote}#{idMap.get id}#{quote}" if idMap.size # some ids to remap svg = svg.replace /(? href = href.trim() if href.startsWith '#' if (newId = idMap.get href[1..])? href = "##{newId}" else console.warn "SVG #{prefix} has reference to unknown id: #{id}" else while (match = refRegExp.exec attr.value)? oldId = match[1] or match[3] if (newId = idMap.get oldId)? href = href.replace "##{oldId}", "##{newId}" else console.warn "SVG #{prefix} has reference to unknown id: #{oldId}" "#{pre}#{quote}#{href}#{quote}" svg ## Construct unique prefix for IDs from a given filename prefixForFile = new Map prefixCount = new Map prefixForFilename = (filename) -> return prefixForFile.get filename if prefixForFile.has filename prefix = path.basename filename prefix = prefix[0...-path.extname(prefix).length] # Strip characters that need escaping (see escapeId) .replace /^[^a-zA-Z]+|[^\w.-]/g, '' if prefixCount.has prefix while prefixCount.has prefix count = prefixCount.get prefix prefixCount.set prefix, count + 1 prefix = "#{prefix}#{count}" else prefixCount.set prefix, 0 prefixForFile.set filename, prefix prefix #currentMapping = null currentRender = null currentDriver = null currentContext = null #getMapping = -> # ## Returns current `Mapping` object, # ## when used at top level of a JS/CS mapping file. # currentMapping #runWithMapping = (mapping, fn) -> # ## Runs the specified function `fn` as if it were called # ## at the top level of the specified mapping. # oldMapping = currentMapping # currentMapping = mapping # try # fn() # finally # currentMapping = oldMapping getRender = -> ## Returns current `Render` object, when used within its execution. currentRender runWithRender = (render, fn) -> ## Runs the specified function `fn` as if it were called ## from the specified `render`. oldRender = currentRender currentRender = render try fn() finally currentRender = oldRender getDriver = -> ## Returns current `Driver` object, when used within its execution. currentDriver runWithDriver = (driver, fn) -> ## Runs the specified function `fn` as if it were called ## from the specified `driver`. oldDriver = currentDriver currentDriver = driver try fn() finally currentDriver = oldDriver getContext = -> ## Returns current `Context` object, when used within a mapping function. currentContext runWithContext = (context, fn) -> ## Runs the specified function `fn` as if it were called ## within the specified `context`. oldContext = currentContext currentContext = context try fn() finally currentContext = oldContext getContextString = -> ## Returns string describing the current context if currentContext? "tile '#{currentContext.tile}' in row #{currentContext.i+1}, column #{currentContext.j+1} of drawing '#{currentContext.drawing.filename}'" #else if currentMapping? # "mapping '#{currentMapping.filename}'" else if currentRender? "render of '#{currentRender.drawing.filename}'" else 'unknown context' getSettings = -> ### Returns currently active `Settings` object, if any, from * the current Render process (which includes the case of an active Context), * the current Mapping file, * the current Driver process, or * `defaultSettings` if none of the above are currently active. ### #if currentContext? # currentContext.render.settings if currentRender? currentRender.settings #else if currentMapping? # currentMapping.settings else if currentDriver? currentDriver.settings else defaultSettings ## SVG container element tags from ## https://developer.mozilla.org/en-US/docs/Web/SVG/Element#container_elements ## that are useless when empty and have no `id` attribute. emptyContainers = new Set [ 'defs' 'g' 'svg' 'switch' 'symbol' ] preactRenderToDom = null class SVGContent extends HasSettings ### Base helper for parsing SVG as specified in SVG Tiler: SVG strings, Preact VDOM, or filenames, with special handling of image files. Automatically determines `width`, `height`, `viewBox`, `boundingBox`, and `zIndex` properties if specified in the SVG content, and sets `isEmpty` to indicate whether the SVG is a useless empty tag. In many cases (symbols and defs), acquires an `id` property via `setId`, which can be formatted via `url()` and `hash()`. In some cases, acquires `isStatic` Boolean property to indicate re-usable content, or `isForced` Boolean property to indicate a def that should be included by force (even if unused). ### constructor: (@name, @value, @settings) -> ## `@value` can be a string (SVG or filename) or Preact VDOM. super() url: -> "url(##{@id})" hash: -> "##{@id}" force: (value = true) -> @isForced = value @ # allow chaining makeSVG: -> return @svg if @svg? ## Set `@svg` to SVG string for duplication detection. if isPreact @value ## Render Preact virtual dom nodes (e.g. from JSX notation) into strings ## and directly into DOM. @svg = (window?.preactRenderToString?.default ? require('preact-render-to-string')) @value if preactRenderToDom == null try preactRenderToDom = window?.preactRenderToDom?.RenderToDom ? require 'preact-render-to-dom' if preactRenderToDom? if xmldom? preactRenderToDom = new preactRenderToDom.RenderToXMLDom {xmldom, svg: true skipNS: true } else preactRenderToDom = new preactRenderToDom.RenderToDom {svg: true} if preactRenderToDom? @dom = preactRenderToDom.render @value @postprocessDOM() else if typeof @value == 'string' if @value.trim() == '' ## Blank SVG mapped to empty @svg = '' ## This will get width/height 0 in SVGSymbol else unless @value.includes '<' ## No <'s -> interpret as filename filename = resolve @settings?.dirname, @value extension = extensionOf filename ## tag documentation: "Conforming SVG viewers need to ## support at least PNG, JPEG and SVG format files." ## [https://svgwg.org/svg2-draft/embedded.html#ImageElement] switch extension when '.png', '.jpg', '.jpeg', '.gif' @svg = """ """ when '.svg' @filename = filename @settings = {...@settings, dirname: path.dirname filename} @svg = loadSVG @filename, @settings @svg = prefixSVGIds @svg, prefixForFilename filename else throw new SVGTilerError "Unrecognized extension in filename '#{@value}' for #{@name}" else @svg = @value else throw new SVGTilerError "Invalid value for #{@name}: #{typeof @value}" ## Remove initial SVG/XML comments (for broader duplication detection, ## and the next replace rule). @svg = removeSVGComments @svg setId: (@id) -> ## Can be called before or after makeDOM, updating DOM in latter case. @dom.setAttribute 'id', @id if @dom? defaultId: (base = 'id') -> ### Generate a "default" id (typically for use in def) using these rules: 1. If the root element has an `id` attribute, use that (manual spec). 2. Use the root element's tag name, if any 3. Fallback to use first argument `base`, which defaults to `"id"`. The returned id is not yet escaped; you should pass it to `escapeId`. ### doc = @makeDOM() doc.getAttribute('id') or doc.tagName or base makeDOM: -> return @dom if @dom? @makeSVG() ## Force SVG namespace when parsing, so nodes have correct namespaceURI. ## (This is especially important on the browser, so the results can be ## reparented into an HTML Document.) svg = @svg.replace /^\s*<(?:[^<>'"\/]|'[^']*'|"[^"]*")*\s*(\/?\s*>)/, (match, end) -> unless match.includes 'xmlns' match = match[...match.length-end.length] + " xmlns='#{SVGNS}'" + match[match.length-end.length..] match @dom = new DOMParser locator: ## needed when specifying errorHandler line: 1 col: 1 errorHandler: (level, msg, indent = ' ') => msg = msg.replace /^\[xmldom [^\[\]]*\]\t/, '' msg = msg.replace /@#\[line:(\d+),col:(\d+)\]$/, (match, line, col) => lines = svg.split '\n' (if line > 1 then indent + lines[line-2] + '\n' else '') + indent + lines[line-1] + '\n' + indent + ' '.repeat(col-1) + '^^^' + (if line < lines.length then '\n' + indent + lines[line] else '') console.error "SVG parse #{level} in #{@name}: #{msg}" .parseFromString svg, 'image/svg+xml' .documentElement # Web parser creates inside if error = @dom.querySelector? 'parsererror' msg = error.innerHTML .replace /]*>[^]*?<\/h3>/g, '' .replace /<\/?div[^<>]*>/g, '' @dom = error.nextSibling console.error "SVG parse error in #{@name}: #{msg}" @postprocessDOM() @dom postprocessDOM: -> ## Remove from the symbol any top-level xmlns=SVGNS or xmlns:xlink, ## in the original parsed content or possibly added above, ## to avoid conflict with these attributes in the top-level . ## `removeAttribute` won't be defined in the case @dom is DocumentFragment. @dom.removeAttribute? 'xmlns' unless @getSetting 'useHref' @dom.removeAttribute? 'xmlns:xlink' ## Wrap in if appropriate, ## before we add width/height/etc. attributes. @wrap?() ## processing (must come before width/height processing). domRecurse @dom, (node) => if node.nodeName == 'image' ### Fix image-rendering: if unspecified, or if specified as "optimizeSpeed" or "pixelated", attempt to render pixels as pixels, as needed for old-school graphics. SVG 1.1 and Inkscape define image-rendering="optimizeSpeed" for this. Chrome doesn't support this, but supports a CSS3 (or SVG) specification of "image-rendering:pixelated". Combining these seems to work everywhere. ### imageRendering = attributeOrStyle node, 'image-rendering' if not imageRendering? or imageRendering in ['optimizeSpeed', 'pixelated'] node.setAttribute 'image-rendering', 'optimizeSpeed' style = node.getAttribute('style') ? '' style = style.replace /(^|;)\s*image-rendering\s*:\s*\w+\s*($|;)/, (m, before, after) -> before or after or '' style += ';' if style node.setAttribute 'style', style + 'image-rendering:pixelated' ## Read file for width/height detection and/or inlining {href, key} = getHref node filename = resolve @settings.dirname, href if filename? and not /^data:|file:|[a-z]+:\/\//.test filename # skip URLs filedata = null try filedata = fs.readFileSync filename unless window? catch e console.warn "Failed to read image '#{filename}': #{e}" ## Fill in width and/or height if missing width = parseFloat node.getAttribute 'width' height = parseFloat node.getAttribute 'height' if (isNaN width) or (isNaN height) size = null if filedata? and not window? try size = require('image-size') filedata ? filename catch e console.warn "Failed to detect size of image '#{filename}': #{e}" if size? ## If one of width and height is set, scale to match. if not isNaN width node.setAttribute 'height', size.height * (width / size.width) else if not isNaN height node.setAttribute 'width', size.width * (height / size.height) else ## If neither width nor height are set, set both. node.setAttribute 'width', size.width node.setAttribute 'height', size.height ## Inline if filedata? and @getSetting 'inlineImages' type = contentType[extensionOf filename] if type? node.setAttribute "data-filename", path.basename filename if size? node.setAttribute "data-width", size.width node.setAttribute "data-height", size.height node.setAttribute key, "data:#{type};base64,#{filedata.toString 'base64'}" false else true ## Determine whether the symbol is "empty", ## meaning it has no useful content so can be safely omitted. recursiveEmpty = (node, allowId) -> return not node.data if node.nodeType == node.TEXT_NODE return false unless node.nodeType == node.DOCUMENT_FRAGMENT_NODE or emptyContainers.has node.tagName ## `hasAttribute` won't be defined in the case node is DocumentFragment return false unless allowId or not node.hasAttribute? 'id' for child in node.childNodes return false unless recursiveEmpty child true @isEmpty = recursiveEmpty @dom, @emptyWithId ## Determine `viewBox`, `width`, and `height` attributes. @viewBox = parseBox @dom.getAttribute 'viewBox' @width = parseDim @origWidth = @dom.getAttribute 'width' @height = parseDim @origHeight = @dom.getAttribute 'height' ## Check for default width/height specified by caller. if not @width? and @defaultWidth? @dom.setAttribute 'width', @width = @defaultWidth if not @height? and @defaultHeight? @dom.setAttribute 'height', @height = @defaultHeight ## Absent viewBox becomes 0 0 if latter are present ## (but only internal to SVG Tiler, DOM remains unchanged). if @width? and @height? and not @viewBox? and @autoViewBox @viewBox = [0, 0, @width, @height] ## Absent viewBox set to automatic bounding box if requested ## (e.g. in `SVGSymbol`). if not @viewBox? and @autoViewBox ## Treat empty content (e.g. empty fragment) as 0x0. if @isEmpty @viewBox = [0, 0, 0, 0] @dom.setAttribute 'viewBox', @viewBox.join ' ' else if (@viewBox = svgBBox @dom)? @dom.setAttribute 'viewBox', @viewBox.join ' ' ## Absent width/height inherited from viewBox if latter is present, ## in `SVGSymbol` which sets `@autoWidthHeight`. ## Including the width/height in the lets us skip it in . if @viewBox? and @autoWidthHeight unless @width? @dom.setAttribute 'width', @width = @viewBox[2] unless @height? @dom.setAttribute 'height', @height = @viewBox[3] ## Overflow behavior overflow = attributeOrStyle @dom, 'overflow' if not overflow? and @defaultOverflow? @dom.setAttribute 'overflow', overflow = @defaultOverflow @overflowVisible = (overflow? and /^\s*(visible|scroll)\b/.test overflow) ### SVG's `viewBox`, `width`, and `height` attributes have a special rule that "A value of zero disables rendering of the element." [https://www.w3.org/TR/SVG11/coords.html#ViewBoxAttribute] [https://www.w3.org/TR/SVG11/struct.html#SVGElementWidthAttribute] Avoid this if overflow is visible. ### if @overflowVisible if @width == 0 @dom.setAttribute 'width', zeroSizeReplacement if @height == 0 @dom.setAttribute 'height', zeroSizeReplacement if @viewBox? and (@viewBox[2] == 0 or @viewBox[3] == 0) @viewBox[2] = zeroSizeReplacement if @viewBox[2] == 0 @viewBox[3] = zeroSizeReplacement if @viewBox[3] == 0 @dom.setAttribute 'viewBox', @viewBox.join ' ' ## Special SVG Tiler attributes that get extracted from DOM @boundingBox = extractBoundingBox @dom @zIndex = extractZIndex @dom #@isEmpty = @dom.childNodes.length == 0 and # (@emptyWithId or not @dom.hasAttribute 'id') and # emptyContainers.has @dom.tagName return useDOM: -> @makeDOM() ## `@idOverride` sets `id` attribute just in the used DOM, ## without changing the `@id` property of the `SVGContent` object. @dom.setAttribute 'id', @idOverride if @idOverride? ## Clone if content is static, to enable later re-use if @isStatic @dom.cloneNode true else @dom class SVGWrapped extends SVGContent ### Abstract base class for `SVGSymbol` and `SVGSVG` which automatically wrap content in a containing element (`` or `` respectively). Subclass should define `wrapper` of 'symbol' or 'svg'. Parser will enforce that the content is wrapped in this element. ### wrap: -> ## Wrap XML in . symbol = @dom.ownerDocument.createElementNS SVGNS, @wrapper ## Force `id` to be first attribute. symbol.setAttribute 'id', @id or '' ## Avoid a layer of indirection for / at top level if @dom.nodeName in ['symbol', 'svg'] for attribute in @dom.attributes unless attribute.name in ['version', 'id'] or attribute.name.startsWith 'xmlns' symbol.setAttribute attribute.name, attribute.value for child in (node for node in @dom.childNodes) symbol.appendChild child @dom.parentNode?.replaceChild symbol, @dom else ## Allow top-level object to specify data. for attribute in ['viewBox', 'boundingBox', 'z-index', 'overflow', 'width', 'height'] ## `width` and `height` have another meaning in e.g. s, ## so just transfer for tags where they are meaningless. continue if attribute in ['width', 'height'] and @dom.tagName not in ['g'] ## `hasAttribute` won't be defined in the case @dom is DocumentFragment if @dom.hasAttribute? attribute symbol.setAttribute attribute, @dom.getAttribute attribute @dom.removeAttribute attribute parent = @dom.parentNode symbol.appendChild @dom parent?.appendChild symbol @dom = symbol #class SVGSVG extends SVGWrapped # ### # SVG content wrapped in ``. # ### # wrapper: 'svg' class SVGSymbol extends SVGWrapped ### SVG content wrapped in ``, with special width/height handling and text extraction, used for tiles. Note though that one `SVGSymbol` may be re-used in many different `Tile`s. ### wrapper: 'symbol' autoViewBox: true autoWidthHeight: true emptyWithId: true # consider empty even if has id attribute postprocessDOM: -> ## Special defaults for loading symbols in `SVGContent`'s `makeDOM`. @defaultWidth = @getSetting 'forceWidth' @defaultHeight = @getSetting 'forceHeight' @defaultOverflow = @getSetting 'overflowDefault' ## `SVGContent` sets `@width` and `@height` according to ## `width`/`height`/`viewBox` attributes or our defaults. super() ## Detect special `width="auto"` and/or `height="auto"` fields for future ## processing, and remove them to ensure valid SVG. @autoWidth = isAuto @origWidth @autoHeight = isAuto @origHeight ## Remove `width="auto"` and `height="auto"` attributes ## (or whatever `SVGContent` set them to). @dom.removeAttribute 'width' if @autoWidth @dom.removeAttribute 'height' if @autoHeight ## Warning for missing width/height. warnings = [] unless @width? warnings.push 'width' @width = 0 unless @height? warnings.push 'height' @height = 0 if warnings.length @sizeWarning = -> console.warn "Failed to detect #{warnings.join ' and '} of SVG for #{@name}" ## Optionally extract nodes for LaTeX output if @getSetting 'texText' @text = [] domRecurse @dom, (node, parent) => if node.nodeName == 'text' @text.push node node.parentNode.removeChild node false # don't recurse into 's children else true @dom ## Tile to fall back to when encountering an unrecognized key. ## Path from https://commons.wikimedia.org/wiki/File:Replacement_character.svg ## by Amit6, released into the public domain. unrecognizedSymbol = new SVGSymbol 'unrecognized tile', ''' ''' unrecognizedSymbol.id = '_unrecognized' # cannot be output of escapeId() unrecognizedSymbol.isStatic = true # need to clone on use ## Tile to fall back to when encountering an error during tile evaluation. ## Path from https://commons.wikimedia.org/wiki/File:Caution_icon_-_Noun_Project_9556_white.svg ## by José Hernandez, SE, released under CC-BY-SA 3.0. ## This symbol is hereby released under the same CC-BY-SA 3.0 license. errorSymbol = new SVGSymbol 'error tile', ''' ''' errorSymbol.id = '_error' # cannot be output of escapeId() errorSymbol.isStatic = true # need to clone on use class Input extends HasSettings ### Abstract base class for all inputs to SVG Tiler, in particular `Mapping`, `Style`, and `Drawing` and their format-specific subclasses. Each subclass should define: * `parse(data)` method that parses the input contents in the format defined by the subclass (specified manually by the user, or automatically read from the input file). * `skipRead: true` attribute if you don't want `@parseFile` class method to read the file data and pass it into `parse`, in case you want to read from `@filename` directly yourself in a specific way. ### constructor: (data, opts) -> ### `data` is input-specific data for direct creation (e.g. without a file), which is processed via a `parse` method (which subclass must define). `opts` is an object with options attached directly to the Input (*before* `parse` gets called), including `filename` and `settings`. ### super() @[key] = value for key, value of opts if opts? @parse data @encoding: 'utf8' @parseFile: (filename, filedata, settings) -> ### Generic method to parse file once we're already in the correct subclass. Automatically reads the file contents from `filename` unless * `@skipRead` is true, or * file contents are specified via `settings.filedata`. Use this to avoid attempting to use the file system on the browser. ### modified = -Infinity try modified = (fs.statSync filename).mtimeMs unless filedata? or @skipRead filedata = fs.readFileSync filename, encoding: @encoding new @ filedata, {filename, modified, settings} @recognize: (filename, filedata, settings) -> ### Recognize type of file and call corresponding class's `parseFile`. Meant to be used as `Input.recognize(...)` via top-level Input class, without specific subclass. ### if extensionMap.hasOwnProperty (extension = extensionOf filename) extensionMap[extension].parseFile filename, filedata, settings #else if filenameMap.hasOwnProperty (lower = filename.toLowerCase()) # filenameMap[lower].parseFile filename, filedata, settings else throw new SVGTilerError "Unrecognized extension in filename #{filename}" dependsOn: (@deps) -> for dep in @deps try modified = (fs.statSync dep).mtimeMs continue unless modified? @modified = Math.max @modified, modified filenameSeparator: '_' generateFilename: (ext, filename = @filename, subname = @subname) -> filename = path.parse filename delete filename.base # force generation from filename.name & filename.ext if (outputStem = @getSetting 'outputStem')? filename.name = outputStem.replace '*', filename.name if subname filename.name += (@filenameSeparator ? '') + subname if filename.ext == ext filename.ext += ext else filename.ext = ext if (outputDir = @getOutputDir ext)? filename.dir = outputDir path.format filename class Style extends Input ### Base Style class assumes any passed data is in CSS format, stored in `@css` attribute. ### parse: (@css) -> class CSSStyle extends Style ## Style in CSS format. Equivalent to Style base class. @title: "CSS style file" class StylusStyle extends Style ## Style in Stylus format. @title: "Stylus style file (https://stylus-lang.com/)" parse: (stylus) -> styl = require('stylus') stylus, filename: @filename super styl.render() class ArrayWrapper extends Array ### Array-like object (indeed, Array subclass) where each item in the array is supposed to be of a fixed class, given by the `@itemClass` class attribute. For example, `Styles` is like an array of `Style`s; and `Mappings` is like an array of `Mapping`s. ### constructor: (...items) -> ### Enforce `items` to be instances of `@itemClass`. Supported formats: * `ArrayWrapper` (just clone array) * `@itemClass` (wrap in singleton) * raw data to pass to `new @itemClass` * `Array` of `@itemClass` * `Array` of raw data to pass to `new @itemClass` * `Array` of a mixture * `undefined`/`null` (empty) ### super() for item in items if item instanceof @constructor @push ...item else if item instanceof @constructor.itemClass @push item else @push new @constructor.itemClass item class Styles extends ArrayWrapper @itemClass: Style parseIntoArgs = (text) -> ## Parse string into an array of arguments, similar to shells. re = /// " (?: [^"\\] | \\. )* " # double-quoted string | ' [^']* '? # single-quoted string: no escape processing | \\. # escaped character | \#.* # comment | (\s+) # top-level whitespace separates args | [^"'\\\#\s]+ # everything else | . # for failed matches like "foo ///g args = [] arg = '' while (match = re.exec text)? chunk = match[0] if match[1] # whitespace args.push arg if arg arg = '' else switch chunk[0] when '"' ## Double-quoted strings escape all glob patterns, and ## process argument-specific escapes processed, ## leaving rest (e.g. `\*`) for glob processing. chunk = chunk[1...-1] .replace /\\(["'\-#])/g, '$1' .replace /[\*\+\?\!\|\@\(\)\[\]\{\}]/g, '\\$&' when "'" ## Single-quoted strings escape all backslashes and glob patterns ## so they will be treated literally (as in bash). chunk = chunk[1...-1] .replace /[\\\*\+\?\!\|\@\(\)\[\]\{\}]/g, '\\$&' when '\\' ## Process escaping specific to argument parsing, ## leaving rest (e.g. `\*`) for glob processing. chunk = chunk[1] if chunk[1] in ['"', "'", '-', '#'] when '#' continue arg += chunk args.push arg if arg args class Args extends Input ### Base args list stores in `@args` an array of strings such as "-p" and "filename.asc". ### parse: (@args) -> doInit: -> makeRule: (key) -> ## Only default rule '' defined by Args. return if key ## Run child driver with arguments. svgtiler @args return true class ParsedArgs extends Args ### `.args` files are represented as an ASCII file that's parsed like a shell, allowing quoted strings, backslash escapes, comments via `#`, and newlines treated like regular whitespace. ### @title: "Additional command-line arguments parsed like a shell" parse: (text) -> super parseIntoArgs text multiCall = (func, arg) -> ## Call function with this and first argument set to `arg`. ## Allow arrays as shorthand for multiple functions to call. return unless func? if Array.isArray arg for item in arg.flat Infinity func.call arg, arg else func.call arg, arg class Mapping extends Input ### Base Mapping class. The passed-in data should be an object (or another `Mapping`) representing the exports from a JavaScript mapping file. The following object properties are optional: * `map`: an object, a Map, or a function resolving to one of the above or a String (containing SVG or a filename), Preact VDOM, or null/undefined, with Arrays possibly mixed in; or another `Mapping`. * `init`: function called when this mapping file gets instantiated. * `preprocess`: function called with a `Render` instance at the beginning. * `postprocess`: function called with a `Render` instance at the end. As shorthand, you can pass in just a Map, Array, or function and it will be treated as a `map`. In this class and subclasses, `@map`, `@init`, `@preprocess`, and `@postprocess` store this data. ### @properties = ['map', 'init', 'preprocess', 'postprocess'] constructor: (data, opts) -> super data, opts @cache ?= new Map # for static tiles @settings = {...@settings, dirname: path.dirname @filename} if @filename? parse: (data) -> if typeof data == 'function' or data instanceof Map or data instanceof WeakMap or Array.isArray data data = map: data if data? {@map, @init, @preprocess, @postprocess, default: defaultMap} = data @map ?= defaultMap # use `default` if no `map` property unless typeof @map in ['function', 'object', 'undefined'] console.warn "Mapping file #{@filename} returned invalid mapping data of type (#{typeof @map}): should be function or object" @map = null if isPreact @map console.warn "Mapping file #{@filename} returned invalid mapping data (Preact DOM): should be function or object" @map = null lookup: (key, context) -> ## `key` normally should be a String (via `AutoDrawing::parse` coercion). ## Don't do anything if this is an empty mapping. return unless @map? ## Check cache (used for static tiles). if (found = @cache.get key)? return found ## Repeatedly expand `@map` until we get string, Preact VDOM, or ## null/undefined. Arrays get expanded recursively. recurse = (value, isStatic = undefined) => while value? #console.log key, ( # switch # when Array.isArray value then 'array' # when isPreact value then 'preact' # else typeof value #), isStatic if typeof value == 'string' or isPreact value ## Static unless we saw a function and no static wrapper. isStatic ?= true ## Symbol ends up getting `isStatic` set to global `isStatic` value, ## instead of this local value. For example, not helpful to mark ## this symbol as static if another one in an array isn't static. value = new SVGSymbol "tile '#{key}'", value, @settings #value.isStatic = isStatic return {value, isStatic} else if value instanceof Map or value instanceof WeakMap value = value.get key else if typeof value == 'function' value = value.call context, key, context ## Use of a function implies dynamic, unless there's a static wrapper. isStatic ?= false else if $static of value # static wrapper from wrapStatic value = value[$static] ## Static wrapper forces static, even if there are functions. isStatic = true else if value instanceof Mapping or value instanceof Mappings return value: value.lookup key, context isStatic: false # no need to cache at higher level else if Array.isArray value # must come after Mappings test ## Items in an array inherit parent staticness if any, ## with no influence between items. ## Overall array is static if every item is. allStatic = true value = for item in value result = recurse item, isStatic allStatic = false if result.isStatic == false result.value return {value, isStatic: allStatic} else if typeof value == 'object' if value.hasOwnProperty key # avoid inherited property e.g. toString value = value[key] else value = undefined else console.warn "Unsupported data type #{typeof value} in looking up tile '#{key}'" value = undefined ## Static unless we saw a function and no static wrapper isStatic ?= true {value, isStatic} {value, isStatic} = recurse @map ## Set each symbol's `isStatic` flag to the global `isStatic` value. ## Enforce arrays to be flat with no nulls. if Array.isArray value value = for symbol in value.flat Infinity when symbol? symbol.isStatic = isStatic symbol else if value? value.isStatic = isStatic ## Save in cache if overall static. @cache.set key, value if isStatic value makeRule: (key) -> executed = false recurse = (value, usedKey) => while value? #if typeof value == 'string' # run value # executed = true # return if value instanceof Map or value instanceof WeakMap value = value.get key usedKey = true else if typeof value == 'function' ## Functions without arguments only match default rule '', ## unless we've already used the rule through object or Map lookup. return if key and not usedKey and not value.length value = value.call @, key, @ ## Function can return `null` (not `undefined`) to say 'no rule'. executed = true unless value == null return else if Array.isArray value for item in value recurse item, usedKey return else if typeof value == 'object' if value.hasOwnProperty key # avoid inherited property e.g. toString value = value[key] usedKey = true else value = undefined else console.warn "Unsupported data type #{typeof value} in looking up Maketile rule '#{key}'" value = undefined value recurse @exports?.make ? @exports?.default executed doInit: -> if @getSetting('verbose') and @init? console.log "# Calling init() in #{@filename}" multiCall @init, @ doPreprocess: (render) -> if @getSetting('verbose') and @preprocess? console.log "# Calling preprocess() in #{@filename}" multiCall @preprocess, render doPostprocess: (render) -> if @getSetting('verbose') and @postprocess? console.log "# Calling postprocess() in #{@filename}" multiCall @postprocess, render ## A fake Mapping file that just sets a `share` key when initialized. ## (Used to implement -s/--share command-line option.) class ShareSetter extends Mapping constructor: (key, value, settings) -> super {init: -> globalShare[key] = value}, {settings, filename: "--share #{key}=#{value}"} class ASCIIMapping extends Mapping @title: "ASCII mapping file" @help: "Each line is " parse: (data) -> map = {} for line in splitIntoLines data separator = whitespace.exec line continue unless separator? if separator.index == 0 if separator[0].length == 1 ## Single whitespace character at beginning defines blank character key = '' else ## Multiple whitespace at beginning defines first whitespace character key = line[0] else key = line[...separator.index] map[key] = line[separator.index + separator[0].length..] super {map} class JSMapping extends Mapping @title: "JavaScript mapping file (including JSX notation)" @help: "Object mapping tile names to TILE e.g. {dot: 'dot.svg'}" @skipRead: true # `require` loads file contents for us parse: (data) -> unless data? ## Normally use `require` to load code as a real NodeJS module filename = path.resolve @filename ## Debug Babel output #if @constructor == JSMapping # {code} = require('@babel/core').transform fs.readFileSync(filename), { # ...babelConfig # filename: @filename # } # console.log code pirates?.settings = @settings #@exports = runWithMapping @, -> require filename @exports = require filename @walkDeps filename #console.log filename, @exports else ## But if file has been explicitly loaded (e.g. in browser), ## compile manually and simulate module. {code} = require('@babel/core').transform data, { ...babelConfig filename: @filename } #console.log code @exports = {} ## Mimick NodeJS module's __filename and __dirname variables ## [https://nodejs.org/api/modules.html#modules_the_module_scope] _filename = path.resolve @filename _dirname = path.dirname _filename ## Use `new Function` instead of `eval` for improved performance and to ## restrict to passed arguments + global scope. #super eval code func = new Function \ 'exports', '__filename', '__dirname', 'svgtiler', 'preact', code #runWithMapping @, -> func @exports, _filename, _dirname, svgtiler, (if code.includes 'preact' then require 'preact') if @getSetting 'verbose' console.log "# Module #{@filename} exported {#{Object.keys(@exports).join ', '}}" super @exports walkDeps: (filename) -> deps = new Set recurse = (modname) => deps.add modname for dep in require.cache[modname]?.deps ? [] unless deps.has dep recurse dep recurse filename @dependsOn (dep for dep from deps) class CoffeeMapping extends JSMapping @title: "CoffeeScript mapping file (including JSX notation)" @help: "Object mapping tile names to TILE e.g. dot: 'dot.svg'" parse: (data) -> unless data? ## Debug CoffeeScript output #{code} = require('@babel/core').transform( # require('coffeescript').compile( # fs.readFileSync(@filename, encoding: 'utf8'), # bare: true # inlineMap: true # filename: @filename # sourceFiles: [@filename]) # {...babelConfig, filename: @filename}) #console.log code ## Normally rely on `require` and `CoffeeScript.register` to load code. super data else ## But if file has been explicitly loaded (e.g. in browser), ## compile manually. super require('coffeescript').compile data, bare: true inlineMap: true filename: @filename sourceFiles: [@filename] class Mappings extends ArrayWrapper @itemClass: Mapping lookup: (key, context) -> return unless @length for i in [@length-1..0] value = @[i].lookup key, context return value if value? undefined doPreprocess: (render) -> ## Run mappings' preprocessing in forward order. for mapping in @ mapping.doPreprocess render doPostprocess: (render) -> ## Run mappings' postprocessing in reverse order. return unless @length for i in [@length-1..0] @[i].doPostprocess render blankCells = new Set [ '' ' ' ## for ASCII art in particular ] allBlank = (list) -> for x in list if x? and not blankCells.has x return false true hrefAttr = (settings) -> if getSetting settings, 'useHref' 'href' else 'xlink:href' maybeWrite = (filename, data) -> ## Writes data to filename, unless the file is already identical to data. ## Returns whether the write actually happened. try if data == fs.readFileSync filename, encoding: 'utf8' return false fs.writeFileSync filename, data true ### This was a possible replacement for calls to maybeWrite, to prevent future calls from running the useless job again, but it doesn't interact well with PDF/PNG conversion: svgink will think it needs to convert. writeOrTouch = (filename, data) -> ## Writes data to filename, unless the file is already identical to data, ## in which case it touches the file (so that we don't keep regenerating it). ## Returns whether the write actually happened. wrote = maybeWrite filename, data unless wrote now = new Date fs.utimesSync filename, now, now wrote ### class Drawing extends Input ### Base Drawing class uses a data format of an Array of Array of keys, where `@keys[i][j]` represents the key in row `i` and column `j`, without any preprocessing. This is meant for direct API use, whereas AutoDrawing provides preprocessing for data from mapping files. In this class and subclasses, `@keys` stores the Array of Array of keys. ### parse: (@keys) -> renderDOM: (settings = @settings) -> new Render @, settings .makeDOM() render: (settings = @settings) -> ## Writes SVG and optionally TeX file. ## Returns output SVG filename. r = new Render @, settings filename = r.writeSVG() r.writeTeX() if getSetting settings, 'texText' filename get: (j, i) -> ## No special negative number handling @keys[i]?[j] set: (j, i, key) -> if i < 0 or j < 0 throw new SVGTilerError "Cannot set key for negative index (#{i}, #{j})" while i >= @keys.length @keys.push [] row = @keys[i] while j >= row.length row.push '' row[j] = key at: (j, i) -> ## Negative numbers wrap around if i < 0 i += @keys.length if j < 0 j += @keys[i]?.length ? 0 @keys[i]?[j] class AutoDrawing extends Drawing ### Extended Drawing base class that preprocesses the drawing as follows: * Casts all keys to strings, in particular to handle Number data. * Optionally removes margins according to `keepMargins` setting. * Optionally pads rows to same length according to `keepUneven` setting. ### parse: (data) -> ## Turn strings into arrays, and turn numbers (e.g. from XLSX) into strings. unless @skipStringCast data = for row in data for cell in row String cell unless @getSetting 'keepMargins' @margins = left: 0 right: 0 top: 0 bottom: 0 ## Top margin while data.length > 0 and allBlank data[0] @margins.top++ data.shift() ## Bottom margin while data.length > 0 and allBlank data[data.length-1] @margins.bottom++ data.pop() if data.length > 0 ## Left margin while allBlank (row[0] for row in data) @margins.left++ for row in data row.shift() ## Right margin j = (Math.max 0, ...(row.length for row in data)) - 1 while j >= 0 and allBlank (row[j] for row in data) @margins.right++ for row in data if j < row.length row.pop() j-- unless @getSetting 'keepUneven' @unevenLengths = (row.length for row in data) width = Math.max 0, ...@unevenLengths for row in data while row.length < width row.push '' super data set: (j, i, key) -> oldHeight = @keys.length super j, i, key ## If we added new rows, make them match row 0's length. unless oldHeight == @keys.length or @getSetting 'keepUneven' for row in @keys[oldHeight..] while row.length < @keys[0].length row.push '' class ASCIIDrawing extends AutoDrawing @title: "ASCII drawing (one character per tile)" parse: (data) -> super( for line in splitIntoLines data graphemeSplitter.splitGraphemes line ) class DSVDrawing extends AutoDrawing ### Abstract base class for all Delimiter-Separator Value (DSV) drawings. Each subclass must define `@delimiter` class property. ### parse: (data) -> ## Remove trailing newline / final blank line. if data[-2..] == '\r\n' data = data[...-2] else if data[-1..] in ['\r', '\n'] data = data[...-1] ## CSV parser. super require('csv-parse/sync').parse data, delimiter: @constructor.delimiter relax_column_count: true class SSVDrawing extends DSVDrawing @title: "Space-delimiter drawing (one word per tile: a b)" @delimiter: ' ' parse: (data) -> ## Coallesce non-newline whitespace into single space super data.replace /[ \t\f\v]+/g, ' ' class CSVDrawing extends DSVDrawing @title: "Comma-separated drawing (spreadsheet export: a,b)" @delimiter: ',' class TSVDrawing extends DSVDrawing @title: "Tab-separated drawing (spreadsheet export: ab)" @delimiter: '\t' class PSVDrawing extends DSVDrawing @title: "Pipe-separated drawing (spreadsheet export: a|b)" @delimiter: '|' class Drawings extends Input parse: (datas) -> @drawings = for data in datas new AutoDrawing data, settings: @settings filename: @filename subname: data.subname render: (settings = @settings) -> ## Writes SVG and optionally TeX files. ## Returns array of output SVG filenames. for drawing in @drawings drawing.render settings class XLSXDrawings extends Drawings @encoding: 'binary' @title: "Spreadsheet drawing(s) (Excel/OpenDocument/Lotus/dBASE)" parse: (data) -> xlsx = require 'xlsx' workbook = xlsx.read data, type: 'binary' ## .ods imports seem to lack Workbook.Sheets metadata unless workbook.Workbook.Sheets? console.warn "Warning: Missing sheet metadata, so can't detect hidden sheets" workbook.Workbook.Sheets = for name in workbook.SheetNames {name} ## https://www.npmjs.com/package/xlsx#common-spreadsheet-format super( for sheetInfo in workbook.Workbook.Sheets subname = sheetInfo.name sheet = workbook.Sheets[subname] ## 0 = Visible, 1 = Hidden, 2 = Very Hidden ## https://sheetjs.gitbooks.io/docs/#sheet-visibility if sheetInfo.Hidden and not @getSetting 'keepHidden' continue if subname.length == 31 console.warn "Warning: Sheet '#{subname}' has length exactly 31, which may be caused by Google Sheets export truncation" rows = xlsx.utils.sheet_to_json sheet, header: 1 defval: '' rows.subname = subname rows ) class DummyInput extends Input parse: -> class SVGFile extends DummyInput @title: "SVG file (convert to PDF/PNG without any tiling)" @skipRead: true # svgink/Inkscape will do actual file reading class Tile ### `Tile` represents a rendered tile, which consists of * the input `key` (usually a `String`) * coordinates: row `i`, column `j`, and layer `k`; * an `SVGSymbol` (`symbol`); and * a layout (`xMin`, `yMin`, `xMax`, `yMax`, `width`, `height`). We also store `zIndex` and `isEmpty` (from the symbol). Note that typically several `Tile`s use the same `SVGSymbol` (assuming some re-use of tiles, e.g., repeated keys). ### constructor: (opts) -> @[key] = value for key, value of opts if opts? @symbol?.sizeWarning?() if @k == 0 ## Do not need to wrap the following elements in . skipDef = new Set [ 'clipPath' 'defs' 'desc' 'filter' 'linearGradient' 'marker' 'mask' 'metadata' 'pattern' 'radialGradient' 'script' 'style' 'title' ] deleteFile = (filename) -> try fs.unlinkSync filename catch err if err.code == 'ENOENT' return false else throw err return true class Render extends HasSettings constructor: (@drawing, @settings) -> super() @settings ?= @drawing.settings @backgroundFill = @getSetting 'background' @idVersions = new Map @mappings = new Mappings @getSetting 'mappings' @styles = new Styles @getSetting 'styles' @defs = [] ## Accumulated rendering, in case add() called before makeDOM(): @xMin = @yMin = @xMax = @yMax = null @layers = {} hrefAttr: -> hrefAttr @settings id: (key) -> #, noEscape) -> ## Generate unique ID starting with an escaped version of `key`. ## If necessary, appends _v0, _v1, _v2, etc. to make unique. key = escapeId key #unless noEscape version = @idVersions.get(key) ? 0 @idVersions.set key, version + 1 if version "#{key}_v#{version}" else key undoId: (key) -> ## Undoes the effect of `@id(key)` by decrementing the version counter. key = escapeId key @idVersions.set key, @idVersions.get(key) - 1 cacheLookup: (def) -> ### Given `SVGContent` for a `def` (e.g. ), check cache by `value` if it's a string (e.g. filename, to avoid loading file again), and by computed SVG string (in case multiple paths lead to the same SVG content). Returns the cached `SVGContent` if found, or `undefined` if new. ### unless typeof def.value == 'string' and (found = @cache.get def.value)? unless def.value == def.makeSVG() unless (found = @cache.get def.svg)? @cache.set def.svg, def @cache.set def.value, def found def: (content) -> content = new SVGContent getContextString(), content, @settings if (found = @cacheLookup content)? found else content.setId @id content.defaultId 'def' @defs.push content content background: (fill) -> ## Sets current background fill to specified value; `null` to remove. ## Returns current background fill. @backgroundFill = fill unless fill == undefined @backgroundFill add: (content, prepend) -> unless content instanceof SVGContent content = new SVGContent \ "svgtiler.add content from #{getContextString()}", content, @settings dom = content.makeDOM() return if content.isEmpty if (box = content.boundingBox ? content.viewBox)? @expandBox box @updateSize() @layers[content.zIndex] ?= [] if prepend @layers[content.zIndex].unshift dom else @layers[content.zIndex].push dom expandBox: (box) -> if box[0]? @xMin = box[0] if not @xMin? or box[0] < @xMin if box[2]? x = box[0] + box[2] @xMax = x if not @xMax? or x > @xMax if box[1]? @yMin = box[1] if not @yMin? or box[1] < @yMin if box[3]? y = box[1] + box[3] @yMax = y if not @yMax? or y > @yMax updateSize: -> if @xMin? and @xMax? @width = @xMax - @xMin else @width = 0 if @yMin? and @yMax? @height = @yMax - @yMin else @height = 0 forEach: (callback) -> runWithContext (new Context @), => for row, i in @drawing.keys for j in [0...row.length] currentContext.move j, i callback.call currentContext, currentContext context: (i, j) -> new Context @, i, j makeDOM: -> runWithRender @, => runWithContext (new Context @), => ### Main rendering engine, returning an xmldom object for the whole document. Also saves the table of `Tile`s (symbols with layout geometry) in `@tiles`, and bounding box in `@xMin`, `@xMax`, `@yMin`, `@yMax`, `@width`, and `@height`. ### doc = domImplementation.createDocument SVGNS, 'svg' @dom = doc.documentElement @dom.setAttribute 'xmlns:xlink', XLINKNS unless @getSetting 'useHref' @dom.setAttribute 'version', '1.1' #@dom.appendChild defs = doc.createElementNS SVGNS, 'defs' ## Preprocess callbacks, which may change anything about the Render job @mappings.doPreprocess @ ##