UNPKG

41.7 kBtext/coffeescriptView Raw
1`#!/usr/bin/env node
2(function() {
3`
4unless window?
5 path = require 'path'
6 fs = require 'fs'
7 xmldom = require 'xmldom'
8 DOMParser = xmldom.DOMParser
9 domImplementation = new xmldom.DOMImplementation()
10 XMLSerializer = xmldom.XMLSerializer
11 prettyXML = require 'prettify-xml'
12 graphemeSplitter = new require('grapheme-splitter')()
13 svgtiler = require '../package.json'
14 require 'coffeescript/register'
15else
16 DOMParser = window.DOMParser # escape CoffeeScript scope
17 domImplementation = document.implementation
18 path =
19 extname: (x) -> /\.[^/]+$/.exec(x)[0]
20 dirname: (x) -> /[^]*\/|/.exec(x)[0]
21
22SVGNS = 'http://www.w3.org/2000/svg'
23XLINKNS = 'http://www.w3.org/1999/xlink'
24
25splitIntoLines = (data) ->
26 data.replace('\r\n', '\n').replace('\r', '\n').split('\n')
27whitespace = /[\s\uFEFF\xA0]+/ ## based on https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim
28
29extensionOf = (filename) -> path.extname(filename).toLowerCase()
30
31class SVGTilerException
32 constructor: (@message) ->
33 toString: ->
34 "svgtiler: #{@message}"
35
36overflowBox = (xml) ->
37 if xml.documentElement.hasAttribute 'overflowBox'
38 xml.documentElement.getAttribute('overflowBox').split /\s*,?\s+/
39 .map parseFloat
40 else
41 null
42
43parseNum = (x) ->
44 parsed = parseFloat x
45 if isNaN parsed
46 null
47 else
48 parsed
49
50svgBBox = (xml) ->
51 ## xxx Many unsupported features!
52 ## - transformations
53 ## - used symbols/defs
54 ## - paths
55 ## - text
56 ## - line widths which extend bounding box
57 if xml.documentElement.hasAttribute 'viewBox'
58 viewBox = xml.documentElement.getAttribute('viewBox').split /\s*,?\s+/
59 .map parseNum
60 if null in viewBox
61 null
62 else
63 viewBox
64 else
65 recurse = (node) ->
66 if node.nodeType != node.ELEMENT_NODE or
67 node.tagName in ['defs', 'symbol', 'use']
68 return null
69 switch node.tagName
70 when 'rect', 'image'
71 ## For <image>, should autodetect image size (#42)
72 [parseNum(node.getAttribute 'x') ? 0
73 parseNum(node.getAttribute 'y') ? 0
74 parseNum(node.getAttribute 'width') ? 0
75 parseNum(node.getAttribute 'height') ? 0]
76 when 'circle'
77 cx = parseNum(node.getAttribute 'cx') ? 0
78 cy = parseNum(node.getAttribute 'cy') ? 0
79 r = parseNum(node.getAttribute 'r') ? 0
80 [cx - r, cy - r, 2*r, 2*r]
81 when 'ellipse'
82 cx = parseNum(node.getAttribute 'cx') ? 0
83 cy = parseNum(node.getAttribute 'cy') ? 0
84 rx = parseNum(node.getAttribute 'rx') ? 0
85 ry = parseNum(node.getAttribute 'ry') ? 0
86 [cx - rx, cy - ry, 2*rx, 2*ry]
87 when 'line'
88 x1 = parseNum(node.getAttribute 'x1') ? 0
89 y1 = parseNum(node.getAttribute 'y1') ? 0
90 x2 = parseNum(node.getAttribute 'x2') ? 0
91 y2 = parseNum(node.getAttribute 'y2') ? 0
92 xmin = Math.min x1, x2
93 ymin = Math.min y1, y2
94 [xmin, ymin, Math.max(x1, x2) - xmin, Math.max(y1, y2) - ymin]
95 when 'polyline', 'polygon'
96 points = for point in node.getAttribute('points').trim().split /\s+/
97 for coord in point.split /,/
98 parseFloat coord
99 xs = (point[0] for point in points)
100 ys = (point[1] for point in points)
101 xmin = Math.min xs...
102 ymin = Math.min ys...
103 if isNaN(xmin) or isNaN(ymin) # invalid points attribute; don't render
104 null
105 else
106 [xmin, ymin, Math.max(xs...) - xmin, Math.max(ys...) - ymin]
107 else
108 viewBoxes = (recurse(child) for child in node.childNodes)
109 viewBoxes = (viewBox for viewBox in viewBoxes when viewBox?)
110 xmin = Math.min ...(viewBox[0] for viewBox in viewBoxes)
111 ymin = Math.min ...(viewBox[1] for viewBox in viewBoxes)
112 xmax = Math.max ...(viewBox[0]+viewBox[2] for viewBox in viewBoxes)
113 ymax = Math.max ...(viewBox[1]+viewBox[3] for viewBox in viewBoxes)
114 [xmin, ymin, xmax - xmin, ymax - ymin]
115 viewBox = recurse xml.documentElement
116 if Infinity in viewBox or -Infinity in viewBox
117 null
118 else
119 viewBox
120
121isAuto = (xml, prop) ->
122 xml.documentElement.hasAttribute(prop) and
123 /^\s*auto\s*$/i.test xml.documentElement.getAttribute prop
124
125attributeOrStyle = (node, attr, styleKey = attr) ->
126 if value = node.getAttribute attr
127 value.trim()
128 else
129 style = node.getAttribute 'style'
130 if style
131 match = /(?:^|;)\s*#{styleKey}\s*:\s*([^;\s][^;]*)/i.exec style
132 match?[1]
133
134zIndex = (node) ->
135 ## Check whether DOM node has a specified z-index, defaulting to zero.
136 ## Note that z-index must be an integer.
137 ## 1. https://www.w3.org/Graphics/SVG/WG/wiki/Proposals/z-index suggests
138 ## a z-index="..." attribute. Check for this first.
139 ## 2. Look for style="z-index:..." as in HTML.
140 z = parseInt attributeOrStyle node, 'z-index'
141 if isNaN z
142 0
143 else
144 z
145
146class Symbol
147 @svgEncoding: 'utf8'
148 @forceWidth: null ## default: no size forcing
149 @forceHeight: null ## default: no size forcing
150 @texText: false
151
152 ###
153 Attempt to render pixels as pixels, as needed for old-school graphics.
154 SVG 1.1 and Inkscape define image-rendering="optimizeSpeed" for this.
155 Chrome doesn't support this, but supports a CSS3 (or SVG) specification of
156 "image-rendering:pixelated". Combining these seems to work everywhere.
157 ###
158 @imageRendering:
159 ' image-rendering="optimizeSpeed" style="image-rendering:pixelated"'
160
161 @parse: (key, data, dirname) ->
162 unless data?
163 throw new SVGTilerException "Attempt to create symbol '#{key}' without data"
164 else if typeof data == 'function'
165 new DynamicSymbol key, data, dirname
166 else if data.function?
167 new DynamicSymbol key, data.function, dirname
168 else
169 ## Render Preact virtual dom nodes (e.g. from JSX notation) into strings.
170 ## Serialization + parsing shouldn't be necessary, but this lets us
171 ## deal with one parsed format (xmldom).
172 if typeof data == 'object' and data.type? and data.props?
173 data = require('preact-render-to-string') data
174 new StaticSymbol key,
175 if typeof data == 'string'
176 if data.trim() == '' ## Blank SVG treated as 0x0 symbol
177 svg: '<symbol viewBox="0 0 0 0"/>'
178 else if data.indexOf('<') < 0 ## No <'s -> interpret as filename
179 if dirname?
180 filename = path.join dirname, data
181 else
182 filename = data
183 extension = extensionOf data
184 ## <image> tag documentation: "Conforming SVG viewers need to
185 ## support at least PNG, JPEG and SVG format files."
186 ## [https://svgwg.org/svg2-draft/embedded.html#ImageElement]
187 switch extension
188 when '.png', '.jpg', '.jpeg', '.gif'
189 size = require('image-size') filename
190 svg: """
191 <image xlink:href="#{encodeURI data}" width="#{size.width}" height="#{size.height}"#{@imageRendering}/>
192 """
193 when '.svg'
194 filename: filename
195 svg: fs.readFileSync filename,
196 encoding: @svgEncoding
197 else
198 throw new SVGTilerException "Unrecognized extension in filename '#{data}' for symbol '#{key}'"
199 else
200 svg: data
201 else
202 data
203 includes: (substring) ->
204 @key.indexOf(substring) >= 0
205 ## ECMA6: @key.includes substring
206
207escapeId = (key) ->
208 ###
209 id/href follows the IRI spec [https://tools.ietf.org/html/rfc3987]:
210 ifragment = *( ipchar / "/" / "?" )
211 ipchar = iunreserved / pct-encoded / sub-delims / ":" / "@"
212 iunreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" / ucschar
213 pct-encoded = "%" HEXDIG HEXDIG
214 sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
215 / "*" / "+" / "," / ";" / "="
216 ucschar = %xA0-D7FF / %xF900-FDCF / #%xFDF0-FFEF
217 / %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD
218 / %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD
219 / %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD
220 / %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD
221 / %xD0000-DFFFD / %xE1000-EFFFD
222 We also want to escape colon (:) which seems to cause trouble.
223 We use encodeURIComponent which escapes everything except
224 A-Z a-z 0-9 - _ . ! ~ * ' ( )
225 [https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent]
226 Unfortunately, Inkscape seems to ignore any %-encoded symbols; see
227 https://bugs.launchpad.net/inkscape/+bug/1737778
228 So we replace '%' with '$', an allowed character that's already escaped.
229 In the special case of a blank key, we use the special $BLANK$ which cannot
230 be generated by the escaping process.
231 ###
232 (encodeURIComponent key
233 .replace /%/g, '$'
234 ) or '$BLANK$'
235
236zeroSizeReplacement = 1
237
238class StaticSymbol extends Symbol
239 constructor: (@key, options) ->
240 super()
241 for own key, value of options
242 @[key] = value
243 ## Force SVG namespace when parsing, so nodes have correct namespaceURI.
244 ## (This is especially important on the browser, so the results can be
245 ## reparented into an HTML Document.)
246 @svg = @svg.replace /^\s*<(?:[^<>'"\/]|'[^']*'|"[^"]*")*\s*(\/?\s*>)/,
247 (match, end) ->
248 unless 'xmlns' in match
249 match = match[...match.length-end.length] +
250 " xmlns='#{SVGNS}'" + match[match.length-end.length..]
251 match
252 @xml = new DOMParser
253 locator: ## needed when specifying errorHandler
254 line: 1
255 col: 1
256 errorHandler: (level, msg, indent = ' ') =>
257 msg = msg.replace /^\[xmldom [^\[\]]*\]\t/, ''
258 msg = msg.replace /@#\[line:(\d+),col:(\d+)\]$/, (match, line, col) =>
259 lines = @svg.split '\n'
260 (if line > 1 then indent + lines[line-2] + '\n' else '') +
261 indent + lines[line-1] + '\n' +
262 indent + ' '.repeat(col-1) + '^^^' +
263 (if line < lines.length then '\n' + indent + lines[line] else '')
264 console.error "SVG parse ${level} in symbol '#{@key}': #{msg}"
265 .parseFromString @svg, 'image/svg+xml'
266 # Remove from the symbol any top-level xmlns=SVGNS possibly added above:
267 # we will have such a tag in the top-level <svg>.
268 @xml.documentElement.removeAttribute 'xmlns'
269 @viewBox = svgBBox @xml
270 @overflowBox = overflowBox @xml
271 overflow = attributeOrStyle @xml.documentElement, 'overflow'
272 @overflowVisible = (overflow? and /^visible\b/.test overflow)
273 @width = @height = null
274 if @viewBox?
275 @width = @viewBox[2]
276 @height = @viewBox[3]
277 ###
278 SVG's viewBox has a special rule that "A value of zero [in <width>
279 or <height>] disables rendering of the element." Avoid this.
280 [https://www.w3.org/TR/SVG11/coords.html#ViewBoxAttribute]
281 ###
282 if @overflowVisible
283 if @width == 0
284 @viewBox[2] = zeroSizeReplacement
285 if @height == 0
286 @viewBox[3] = zeroSizeReplacement
287 if Symbol.forceWidth?
288 @width = Symbol.forceWidth
289 if Symbol.forceHeight?
290 @height = Symbol.forceHeight
291 warnings = []
292 unless @width?
293 warnings.push 'width'
294 @width = 0
295 unless @height?
296 warnings.push 'height'
297 @height = 0
298 if warnings.length > 0
299 console.warn "Failed to detect #{warnings.join ' and '} of SVG for symbol '#{@key}'"
300 ## Detect special `width="auto"` and/or `height="auto"` fields for future
301 ## processing, and remove them to ensure valid SVG.
302 @autoWidth = isAuto @xml, 'width'
303 @autoHeight = isAuto @xml, 'height'
304 @xml.documentElement.removeAttribute 'width' if @autoWidth
305 @xml.documentElement.removeAttribute 'height' if @autoHeight
306 @zIndex = zIndex @xml.documentElement
307 ## Optionally extract <text> nodes for LaTeX output
308 if Symbol.texText
309 @text = []
310 extract = (node) =>
311 return unless node.hasChildNodes()
312 child = node.lastChild
313 while child?
314 nextChild = child.previousSibling
315 if child.nodeName == 'text'
316 @text.push child
317 node.removeChild child
318 else
319 extract child
320 child = nextChild
321 extract @xml.documentElement
322 id: ->
323 escapeId @key
324 use: -> @ ## do nothing for static symbol
325 usesContext: false
326
327class DynamicSymbol extends Symbol
328 @all: []
329 constructor: (@key, @func, @dirname) ->
330 super()
331 @versions = {}
332 @nversions = 0
333 @constructor.all.push @
334 @resetAll: ->
335 ## Resets all DynamicSymbol's versions to 0.
336 ## Use before starting a new SVG document.
337 for symbol in @all
338 symbol.versions = {}
339 symbol.nversions = 0
340 use: (context) ->
341 result = @func.call context
342 unless result?
343 throw new Error "Function for symbol #{@key} returned #{result}"
344 ## We use JSON serialization to detect duplicate symbols. This enables
345 ## return values like {filename: ...} and JSX virtual dom elements,
346 ## in addition to raw SVG strings.
347 string = JSON.stringify result
348 unless string of @versions
349 version = @nversions++
350 @versions[string] =
351 Symbol.parse "#{@key}$v#{version}", result, @dirname
352 @versions[string].id = => "#{escapeId @key}$v#{version}"
353 @versions[string]
354 usesContext: true
355
356## Symbol to fall back to when encountering an unrecognized symbol.
357## Path from https://commons.wikimedia.org/wiki/File:Replacement_character.svg
358## by Amit6, released into the public domain.
359unrecognizedSymbol = new StaticSymbol '$UNRECOGNIZED$', svg: '''
360 <symbol viewBox="0 0 200 200" preserveAspectRatio="none" width="auto" height="auto">
361 <rect width="200" height="200" fill="yellow"/>
362 <path xmlns="http://www.w3.org/2000/svg" stroke="none" fill="red" d="M 200,100 100,200 0,100 100,0 200,100 z M 135.64709,74.70585 q 0,-13.52935 -10.00006,-22.52943 -9.99999,-8.99999 -24.35289,-8.99999 -17.29415,0 -30.117661,5.29409 L 69.05879,69.52938 q 9.764731,-6.23528 21.52944,-6.23528 8.82356,0 14.58824,4.82351 5.76469,4.82351 5.76469,12.70589 0,8.5883 -9.94117,21.70588 -9.94117,13.11766 -9.94117,26.76473 l 17.88236,0 q 0,-6.3529 6.9412,-14.9412 11.76471,-14.58816 12.82351,-16.35289 6.9412,-11.05887 6.9412,-23.29417 z m -22.00003,92.11771 0,-24.70585 -27.29412,0 0,24.70585 27.29412,0 z"/>
363 </symbol>
364'''
365unrecognizedSymbol.id = -> '$UNRECOGNIZED$' # cannot be output of standard id()
366
367class Input
368 @encoding: 'utf8'
369 @parseFile: (filename, filedata) ->
370 ## Generic method to parse file once we're already in the right class.
371 input = new @
372 input.filename = filename
373 unless filedata?
374 filedata = fs.readFileSync filename, encoding: @encoding
375 input.parse filedata
376 input
377 @recognize: (filename, filedata) ->
378 ## Recognize type of file and call corresponding class's `parseFile`.
379 extension = extensionOf filename
380 if extension of extensionMap
381 extensionMap[extension].parseFile filename, filedata
382 else
383 throw new SVGTilerException "Unrecognized extension in filename #{filename}"
384
385class Mapping extends Input
386 load: (data) ->
387 @map = {}
388 if typeof data == 'function'
389 @function = data
390 else
391 @merge data
392 merge: (data) ->
393 dirname = path.dirname @filename if @filename?
394 for own key, value of data
395 unless value instanceof Symbol
396 value = Symbol.parse key, value, dirname
397 @map[key] = value
398 lookup: (key) ->
399 key = key.toString() ## Sometimes get a number, e.g., from XLSX
400 if key of @map
401 @map[key]
402 else if @function?
403 ## Cache return value of function so that only one Symbol generated
404 ## for each key. It still may be a DynamicSymbol, which will allow
405 ## it to make multiple versions, but keep track of which are the same.
406 value = @function key
407 if value?
408 @map[key] = Symbol.parse key, value
409 else
410 value
411 else
412 undefined
413
414class ASCIIMapping extends Mapping
415 @title: "ASCII mapping file"
416 @help: "Each line is <symbol-name><space><raw SVG or filename.svg>"
417 parse: (data) ->
418 map = {}
419 for line in splitIntoLines data
420 separator = whitespace.exec line
421 continue unless separator?
422 if separator.index == 0
423 if separator[0].length == 1
424 ## Single whitespace character at beginning defines blank character
425 key = ''
426 else
427 ## Multiple whitespace at beginning defines first whitespace character
428 key = line[0]
429 else
430 key = line[...separator.index]
431 map[key] = line[separator.index + separator[0].length..]
432 @load map
433
434class JSMapping extends Mapping
435 @title: "JavaScript mapping file"
436 @help: "Object mapping symbol names to SYMBOL e.g. dot: 'dot.svg'"
437 parse: (data) ->
438 {code} = require('@babel/core').transform data,
439 filename: @filename
440 plugins: [[require.resolve('@babel/plugin-transform-react-jsx'),
441 useBuiltIns: true
442 pragma: 'preact.h'
443 pragmaFrag: 'preact.Fragment'
444 throwIfNamespace: false
445 ]]
446 sourceMaps: 'inline'
447 retainLines: true
448 if 0 <= code.indexOf 'preact.'
449 code = "var preact = require('preact'), h = preact.h; #{code}"
450 ## Mimick NodeJS module's __filename and __dirname variables.
451 ## Redirect require() to use paths relative to the mapping file.
452 ## xxx should probably actually create a NodeJS module when possible
453 __filename = path.resolve @filename
454 code =
455 "var __filename = #{JSON.stringify __filename},
456 __dirname = #{JSON.stringify path.dirname __filename},
457 __require = require;
458 require = (module) => __require(module.startsWith('.') ? __require('path').resolve(__dirname, module) : module);
459 #{code}\n//@ sourceURL=#{@filename}"
460 @load eval code
461
462class CoffeeMapping extends JSMapping
463 @title: "CoffeeScript mapping file"
464 @help: "Object mapping symbol names to SYMBOL e.g. dot: 'dot.svg'"
465 parse: (data) ->
466 #try
467 super.parse require('coffeescript').compile data,
468 bare: true
469 filename: @filename
470 sourceFiles: [@filename]
471 inlineMap: true
472 #catch err
473 # throw err
474 # if err.stack? and err.stack.startsWith "#{@filename}:"
475 # sourceMap = require('coffeescript').compile(data,
476 # bare: true
477 # filename: @filename
478 # sourceFiles: [@filename]
479 # sourceMap: true
480 # ).sourceMap
481 # err.stack = err.stack.replace /:([0-9]*)/, (m, line) ->
482 # ## sourceMap starts line numbers at 0, but we want to work from 1
483 # for col in sourceMap?.lines[line-1]?.columns ? [] when col?.sourceLine?
484 # unless sourceLine? and sourceLine < col.sourceLine
485 # sourceLine = col.sourceLine
486 # line = sourceLine + 1
487 # ":#{line}"
488 # throw err
489
490class Mappings
491 constructor: (@maps = []) ->
492 push: (map) ->
493 @maps.push map
494 lookup: (key) ->
495 return unless @maps.length
496 for i in [@maps.length-1..0]
497 value = @maps[i].lookup key
498 return value if value?
499 undefined
500
501blankCells =
502 '': true
503 ' ': true ## for ASCII art in particular
504
505allBlank = (list) ->
506 for x in list
507 if x? and x not of blankCells
508 return false
509 true
510
511class Drawing extends Input
512 load: (data) ->
513 ## Turn strings into arrays
514 data = for row in data
515 for cell in row
516 cell
517 unless Drawing.keepMargins
518 ## Top margin
519 while data.length > 0 and allBlank data[0]
520 data.shift()
521 ## Bottom margin
522 while data.length > 0 and allBlank data[data.length-1]
523 data.pop()
524 if data.length > 0
525 ## Left margin
526 while allBlank (row[0] for row in data)
527 for row in data
528 row.shift()
529 ## Right margin
530 j = Math.max (row.length for row in data)...
531 while j >= 0 and allBlank (row[j] for row in data)
532 for row in data
533 if j < row.length
534 row.pop()
535 j--
536 @data = data
537 writeSVG: (mappings, filename) ->
538 ## Default filename is the input filename with extension replaced by .svg
539 unless filename?
540 filename = path.parse @filename
541 if filename.ext == '.svg'
542 filename.base += '.svg'
543 else
544 filename.base = filename.base[...-filename.ext.length] + '.svg'
545 filename = path.format filename
546 console.log '->', filename
547 fs.writeFileSync filename, @renderSVG mappings
548 filename
549 renderSVGDOM: (mappings) ->
550 ###
551 Main rendering engine, returning an xmldom object for the whole document.
552 Also saves the table of symbols in `@symbols`, the corresponding
553 coordinates in `@coords`, and overall `@weight` and `@height`,
554 for use by `renderTeX`.
555 ###
556 DynamicSymbol.resetAll()
557 doc = domImplementation.createDocument SVGNS, 'svg'
558 svg = doc.documentElement
559 svg.setAttribute 'xmlns:xlink', XLINKNS
560 svg.setAttribute 'version', '1.1'
561 #svg.appendChild defs = doc.createElementNS SVGNS, 'defs'
562 ## Look up all symbols in the drawing.
563 missing = {}
564 @symbols =
565 for row in @data
566 for cell in row
567 symbol = mappings.lookup cell
568 if symbol?
569 lastSymbol = symbol
570 else
571 missing[cell] = true
572 unrecognizedSymbol
573 missing = ("'#{key}'" for own key of missing)
574 if missing.length
575 console.warn "Failed to recognize symbols:", missing.join ', '
576 ## Instantiate (.use) all (dynamic) symbols in the drawing.
577 symbolsByKey = {}
578 @symbols =
579 for row, i in @symbols
580 for symbol, j in row
581 if symbol.usesContext
582 symbol = symbol.use new Context @, i, j
583 else
584 symbol = symbol.use()
585 unless symbol.key of symbolsByKey
586 symbolsByKey[symbol.key] = symbol
587 else if symbolsByKey[symbol.key] is not symbol
588 console.warn "Multiple symbols with key #{symbol.key}"
589 symbol
590 ## Include all used symbols in SVG
591 for key, symbol of symbolsByKey
592 continue unless symbol?
593 svg.appendChild node = doc.createElementNS SVGNS, 'symbol'
594 node.setAttribute 'id', symbol.id()
595 if symbol.xml.documentElement.tagName in ['svg', 'symbol']
596 ## Remove a layer of indirection for <svg> and <symbol>
597 for attribute in symbol.xml.documentElement.attributes
598 unless attribute.name in ['version', 'id'] or attribute.name[...5] == 'xmlns'
599 node.setAttribute attribute.name, attribute.value
600 for child in symbol.xml.documentElement.childNodes
601 node.appendChild child.cloneNode true
602 else
603 node.appendChild symbol.xml.documentElement.cloneNode true
604 ## Set/overwrite any viewbox attribute with one from symbol.
605 if symbol.viewBox?
606 node.setAttribute 'viewBox', symbol.viewBox
607 ## Lay out the symbols in the drawing via SVG <use>.
608 viewBox = [0, 0, 0, 0] ## initially x-min, y-min, x-max, y-max
609 levels = {}
610 y = 0
611 colWidths = {}
612 @coords = []
613 for row, i in @symbols
614 @coords.push coordsRow = []
615 rowHeight = 0
616 for symbol in row when not symbol.autoHeight
617 if symbol.height > rowHeight
618 rowHeight = symbol.height
619 x = 0
620 for symbol, j in row
621 coordsRow.push {x, y}
622 continue unless symbol?
623 levels[symbol.zIndex] ?= []
624 levels[symbol.zIndex].push use = doc.createElementNS SVGNS, 'use'
625 use.setAttribute 'xlink:href', '#' + symbol.id()
626 use.setAttributeNS SVGNS, 'x', x
627 use.setAttributeNS SVGNS, 'y', y
628 scaleX = scaleY = 1
629 if symbol.autoWidth
630 colWidths[j] ?= Math.max 0, ...(
631 for row2 in @symbols when row2[j]? and not row2[j].autoWidth
632 row2[j].width
633 )
634 scaleX = colWidths[j] / symbol.width unless symbol.width == 0
635 scaleY = scaleX unless symbol.autoHeight
636 if symbol.autoHeight
637 scaleY = rowHeight / symbol.height unless symbol.height == 0
638 scaleX = scaleY unless symbol.autoWidth
639 ## Scaling of symbol is relative to viewBox, so use that to define
640 ## width and height attributes:
641 use.setAttributeNS SVGNS, 'width',
642 (symbol.viewBox?[2] ? symbol.width) * scaleX
643 use.setAttributeNS SVGNS, 'height',
644 (symbol.viewBox?[3] ? symbol.height) * scaleY
645 if symbol.overflowBox?
646 dx = (symbol.overflowBox[0] - symbol.viewBox[0]) * scaleX
647 dy = (symbol.overflowBox[1] - symbol.viewBox[1]) * scaleY
648 viewBox[0] = Math.min viewBox[0], x + dx
649 viewBox[1] = Math.min viewBox[1], y + dy
650 viewBox[2] = Math.max viewBox[2], x + dx + symbol.overflowBox[2] * scaleX
651 viewBox[3] = Math.max viewBox[3], y + dy + symbol.overflowBox[3] * scaleY
652 x += symbol.width * scaleX
653 viewBox[2] = Math.max viewBox[2], x
654 y += rowHeight
655 viewBox[3] = Math.max viewBox[3], y
656 ## Change from x-min, y-min, x-max, y-max to x-min, y-min, width, height
657 viewBox[2] = viewBox[2] - viewBox[0]
658 viewBox[3] = viewBox[3] - viewBox[1]
659 ## Sort by level
660 levelOrder = (level for level of levels).sort (x, y) -> x-y
661 for level in levelOrder
662 for node in levels[level]
663 svg.appendChild node
664 svg.setAttributeNS SVGNS, 'viewBox', viewBox.join ' '
665 svg.setAttributeNS SVGNS, 'width', @width = viewBox[2]
666 svg.setAttributeNS SVGNS, 'height', @height = viewBox[3]
667 svg.setAttributeNS SVGNS, 'preserveAspectRatio', 'xMinYMin meet'
668 doc
669 renderSVG: (mappings) ->
670 out = new XMLSerializer().serializeToString @renderSVGDOM mappings
671 ## Parsing xlink:href in user's SVG fragments, and then serializing,
672 ## can lead to these null namespace definitions. Remove.
673 out = out.replace /\sxmlns:xlink=""/g, ''
674 out = prettyXML out,
675 newline: '\n' ## force consistent line endings, not require('os').EOL
676 '''
677<?xml version="1.0" encoding="UTF-8" standalone="no"?>
678<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
679
680''' + out
681 renderTeX: (filename) ->
682 ## Must be called *after* `renderSVG` (or `renderSVGDOM`)
683 filename = path.parse filename
684 basename = filename.base[...-filename.ext.length]
685 ## LaTeX based loosely on Inkscape's PDF/EPS/PS + LaTeX output extension.
686 ## See http://tug.ctan.org/tex-archive/info/svg-inkscape/
687 lines = ["""
688 %% Creator: svgtiler #{svgtiler.version}, https://github.com/edemaine/svgtiler
689 %% This LaTeX file includes and overlays text on top of companion file
690 %% #{basename}.pdf/.png
691 %%
692 %% Instead of \\includegraphics, include this figure via
693 %% \\input{#{filename.base}}
694 %% You can scale the image by first defining \\svg{width,height,scale}:
695 %% \\def\\svgwidth{\\linewidth} % full width
696 %% or
697 %% \\def\\svgheight{5in}
698 %% or
699 %% \\def\\svgscale{0.5} % 50%
700 %% (If multiple are specified, the first in the list above takes priority.)
701 %%
702 %% If this file resides in another directory from the root .tex file,
703 %% you need to help it find its auxiliary .pdf/.png file via one of the
704 %% following options (any one will do):
705 %% 1. \\usepackage{currfile} so that this file can find its own directory.
706 %% 2. \\usepackage{import} and \\import{path/to/file/}{#{filename.base}}
707 %% instead of \\import{#{filename.base}}
708 %% 3. \\graphicspath{{path/to/file/}} % note extra braces and trailing slash
709 %%
710 \\begingroup
711 \\providecommand\\color[2][]{%
712 \\errmessage{You should load package 'color.sty' to render color in svgtiler text.}%
713 \\renewcommand\\color[2][]{}%
714 }%
715 \\ifx\\currfiledir\\undefined
716 \\def\\currfiledir{}%
717 \\fi
718 \\ifx\\svgwidth\\undefined
719 \\ifx\\svgheight\\undefined
720 \\unitlength=0.75bp\\relax % 1px (SVG unit) = 0.75bp (SVG pts)
721 \\ifx\\svgscale\\undefined\\else
722 \\ifx\\real\\undefined % in case calc.sty not loaded
723 \\unitlength=\\svgscale \\unitlength
724 \\else
725 \\setlength{\\unitlength}{\\unitlength * \\real{\\svgscale}}%
726 \\fi
727 \\fi
728 \\else
729 \\unitlength=\\svgheight
730 \\unitlength=#{1/@height}\\unitlength % divide by image height
731 \\fi
732 \\else
733 \\unitlength=\\svgwidth
734 \\unitlength=#{1/@width}\\unitlength % divide by image width
735 \\fi
736 \\def\\clap#1{\\hbox to 0pt{\\hss#1\\hss}}%
737 \\begin{picture}(#{@width},#{@height})%
738 \\put(0,0){\\includegraphics[width=#{@width}\\unitlength]{\\currfiledir #{basename}}}%
739 """]
740 for row, i in @symbols
741 for symbol, j in row
742 {x, y} = @coords[i][j]
743 for text in symbol.text
744 tx = parseNum(text.getAttribute('x')) ? 0
745 ty = parseNum(text.getAttribute('y')) ? 0
746 content = (
747 for child in text.childNodes when child.nodeType == 3 # TEXT_NODE
748 child.data
749 ).join ''
750 anchor = attributeOrStyle text, 'text-anchor'
751 if /^middle\b/.test anchor
752 wrap = '\\clap{'
753 else if /^end\b/.test anchor
754 wrap = '\\rlap{'
755 else #if /^start\b/.test anchor # default
756 wrap = '\\llap{'
757 # "@height -" is to flip between y down (SVG) and y up (picture)
758 lines.push " \\put(#{x+tx},#{@height - (y+ty)}){\\color{#{attributeOrStyle(text, 'fill') or 'black'}}#{wrap}#{content}#{wrap and '}'}}%"
759 lines.push """
760 \\end{picture}%
761 \\endgroup
762 """, '' # trailing newline
763 lines.join '\n'
764 writeTeX: (filename) ->
765 ###
766 Must be called *after* `writeSVG`.
767 Default filename is the input filename with extension replaced by .svg_tex
768 (analogous to .pdf_tex from Inkscape's --export-latex feature, but noting
769 that the text is extracted from the SVG not the PDF, and that this file
770 works with both .pdf and .png auxiliary files).
771 ###
772 unless filename?
773 filename = path.parse @filename
774 if filename.ext == '.svg_tex'
775 filename.base += '.svg_tex'
776 else
777 filename.base = filename.base[...-filename.ext.length] + '.svg_tex'
778 filename = path.format filename
779 console.log ' &', filename
780 fs.writeFileSync filename, @renderTeX filename
781 filename
782
783class ASCIIDrawing extends Drawing
784 @title: "ASCII drawing (one character per symbol)"
785 parse: (data) ->
786 @load(
787 for line in splitIntoLines data
788 graphemeSplitter.splitGraphemes line
789 )
790
791class DSVDrawing extends Drawing
792 parse: (data) ->
793 ## Remove trailing newline / final blank line.
794 if data[-2..] == '\r\n'
795 data = data[...-2]
796 else if data[-1..] in ['\r', '\n']
797 data = data[...-1]
798 ## CSV parser.
799 @load require('csv-parse/lib/sync') data,
800 delimiter: @constructor.delimiter
801 relax_column_count: true
802
803class SSVDrawing extends DSVDrawing
804 @title: "Space-delimiter drawing (one word per symbol)"
805 @delimiter: ' '
806 parse: (data) ->
807 ## Coallesce non-newline whitespace into single space
808 super data.replace /[ \t\f\v]+/g, ' '
809
810class CSVDrawing extends DSVDrawing
811 @title: "Comma-separated drawing (spreadsheet export)"
812 @delimiter: ','
813
814class TSVDrawing extends DSVDrawing
815 @title: "Tab-separated drawing (spreadsheet export)"
816 @delimiter: '\t'
817
818class Drawings extends Input
819 @filenameSeparator: '_'
820 load: (datas) ->
821 @drawings =
822 for data in datas
823 drawing = new Drawing
824 drawing.filename = @filename
825 drawing.subname = data.subname
826 drawing.load data
827 drawing
828 subfilename: (extension, drawing) ->
829 if @drawings.length > 1
830 filename2 = path.parse filename ? @filename
831 filename2.base = filename2.base[...-filename2.ext.length]
832 filename2.base += @constructor.filenameSeparator + drawing.subname
833 filename2.base += extension
834 path.format filename2
835 else
836 drawing?.filename = @filename ## use Drawing default if not filename?
837 filename
838 writeSVG: (mappings, filename) ->
839 for drawing in @drawings
840 drawing.writeSVG mappings, @subfilename '.svg', drawing
841 writeTeX: (filename) ->
842 for drawing in @drawings
843 drawing.writeTeX @subfilename '.svg_tex', drawing
844
845class XLSXDrawings extends Drawings
846 @encoding: 'binary'
847 @title: "Spreadsheet drawing(s) (Excel/OpenDocument/Lotus/dBASE)"
848 parse: (data) ->
849 xlsx = require 'xlsx'
850 workbook = xlsx.read data, type: 'binary'
851 ## https://www.npmjs.com/package/xlsx#common-spreadsheet-format
852 @load (
853 for sheetInfo in workbook.Workbook.Sheets
854 subname = sheetInfo.name
855 sheet = workbook.Sheets[subname]
856 ## 0 = Visible, 1 = Hidden, 2 = Very Hidden
857 ## https://sheetjs.gitbooks.io/docs/#sheet-visibility
858 if sheetInfo.Hidden and not Drawings.keepHidden
859 continue
860 if subname.length == 31
861 console.warn "Warning: Sheet '#{subname}' has length exactly 31, which may be caused by Google Sheets export truncation"
862 rows = xlsx.utils.sheet_to_json sheet,
863 header: 1
864 defval: ''
865 rows.subname = subname
866 rows
867 )
868
869class Context
870 constructor: (@drawing, @i, @j) ->
871 @symbols = @drawing.symbols
872 @filename = @drawing.filename
873 @subname = @drawing.subname
874 @symbol = @symbols[@i]?[@j]
875 @key = @symbol?.key
876 neighbor: (dj, di) ->
877 new Context @drawing, @i + di, @j + dj
878 includes: (args...) ->
879 @symbol? and @symbol.includes args...
880 row: (di = 0) ->
881 i = @i + di
882 for symbol, j in @symbols[i] ? []
883 new Context @drawing, i, j
884 column: (dj = 0) ->
885 j = @j + dj
886 for row, i in @symbols
887 new Context @drawing, i, j
888
889extensionMap =
890 '.txt': ASCIIMapping
891 '.js': JSMapping
892 '.jsx': JSMapping
893 '.coffee': CoffeeMapping
894 '.cjsx': CoffeeMapping
895 '.asc': ASCIIDrawing
896 '.ssv': SSVDrawing
897 '.csv': CSVDrawing
898 '.tsv': TSVDrawing
899 ## Parsable by xlsx package:
900 '.xlsx': XLSXDrawings ## Excel 2007+ XML Format
901 '.xlsm': XLSXDrawings ## Excel 2007+ Macro XML Format
902 '.xlsb': XLSXDrawings ## Excel 2007+ Binary Format
903 '.xls': XLSXDrawings ## Excel 2.0 or 2003-2004 (SpreadsheetML)
904 '.ods': XLSXDrawings ## OpenDocument Spreadsheet
905 '.fods': XLSXDrawings ## Flat OpenDocument Spreadsheet
906 '.dif': XLSXDrawings ## Data Interchange Format (DIF)
907 '.prn': XLSXDrawings ## Lotus Formatted Text
908 '.dbf': XLSXDrawings ## dBASE II/III/IV / Visual FoxPro
909
910sanitize = true
911bufferSize = 16*1024
912
913postprocess = (format, filename) ->
914 return unless sanitize
915 try
916 switch format
917 when 'pdf'
918 ## Blank out /CreationDate in PDF for easier version control.
919 ## Replace these commands with spaces to avoid in-file pointer errors.
920 buffer = Buffer.alloc bufferSize
921 fileSize = fs.statSync(filename).size
922 position = Math.max 0, fileSize - bufferSize
923 file = fs.openSync filename, 'r+'
924 readSize = fs.readSync file, buffer, 0, bufferSize, position
925 string = buffer.toString 'binary' ## must use single-byte encoding!
926 match = /\/CreationDate\s*\((?:[^()\\]|\\[^])*\)/.exec string
927 if match?
928 fs.writeSync file, ' '.repeat(match[0].length), position + match.index
929 fs.closeSync file
930 catch e
931 console.log "Failed to postprocess '#{filename}': #{e}"
932
933convertSVG = (format, svg, sync) ->
934 child_process = require 'child_process'
935 filename = path.parse svg
936 if filename.ext == ".#{format}"
937 filename.base += ".#{format}"
938 else
939 filename.base = "#{filename.base[...-filename.ext.length]}.#{format}"
940 output = path.format filename
941 ## Workaround relative paths not working in MacOS distribution of Inkscape
942 ## [https://bugs.launchpad.net/inkscape/+bug/181639]
943 if process.platform == 'darwin'
944 preprocess = path.resolve
945 else
946 preprocess = (x) -> x
947 args = [
948 '-z'
949 "--file=#{preprocess svg}"
950 "--export-#{format}=#{preprocess output}"
951 ]
952 if sync
953 ## In sychronous mode, we let inkscape directly output its error messages,
954 ## and add warnings about any failures that occur.
955 console.log '=>', output
956 result = child_process.spawnSync 'inkscape', args, stdio: 'inherit'
957 if result.error
958 console.log result.error.message
959 else if result.status or result.signal
960 console.log ":-( #{output} FAILED"
961 else
962 postprocess format, output
963 else
964 ## In asychronous mode, we capture inkscape's outputs, and print them only
965 ## when the process has finished, along with which file failed, to avoid
966 ## mixing up messages from parallel executions.
967 (resolve) ->
968 console.log '=>', output
969 inkscape = require('child_process').spawn 'inkscape', args
970 out = ''
971 inkscape.stdout.on 'data', (buf) -> out += buf
972 inkscape.stderr.on 'data', (buf) -> out += buf
973 inkscape.on 'error', (error) ->
974 console.log error.message
975 inkscape.on 'exit', (status, signal) ->
976 if status or signal
977 console.log ":-( #{output} FAILED:"
978 console.log out
979 else
980 postprocess format, output
981 resolve()
982
983help = ->
984 console.log """
985svgtiler #{svgtiler.version ? "(web)"}
986Usage: #{process.argv[1]} (...options and filenames...)
987Documentation: https://github.com/edemaine/svgtiler
988
989Optional arguments:
990 --help Show this help message and exit.
991 -m / --margin Don't delete blank extreme rows/columns
992 --hidden Process hidden sheets within spreadsheet files
993 --tw TILE_WIDTH / --tile-width TILE_WIDTH
994 Force all symbol tiles to have specified width
995 --th TILE_HEIGHT / --tile-height TILE_HEIGHT
996 Force all symbol tiles to have specified height
997 -p / --pdf Convert output SVG files to PDF via Inkscape
998 -P / --png Convert output SVG files to PNG via Inkscape
999 -t / --tex Move <text> from SVG to accompanying LaTeX file.tex
1000 --no-sanitize Don't sanitize PDF output by blanking out /CreationDate
1001 -j N / --jobs N Run up to N Inkscape jobs in parallel
1002
1003Filename arguments: (mappings before drawings!)
1004
1005"""
1006 for extension, klass of extensionMap
1007 if extension.length < 10
1008 extension += ' '.repeat 10 - extension.length
1009 console.log " *#{extension} #{klass.title}"
1010 console.log " #{klass.help}" if klass.help?
1011 console.log """
1012
1013SYMBOL specifiers: (omit the quotes in anything except .js and .coffee files)
1014
1015 'filename.svg': load SVG from specified file
1016 'filename.png': include PNG image from specified file
1017 'filename.jpg': include JPEG image from specified file
1018 '<svg>...</svg>': raw SVG
1019 -> ...@key...: function computing SVG, with `this` bound to Context with
1020 `key` (symbol name), `i` and `j` (y and x coordinates),
1021 `filename` (drawing filename), `subname` (subsheet name),
1022 and supporting `neighbor`/`includes`/`row`/`column` methods
1023"""
1024 #object with one or more attributes
1025 process.exit()
1026
1027main = ->
1028 mappings = new Mappings
1029 args = process.argv[2..]
1030 files = skip = 0
1031 formats = []
1032 jobs = []
1033 sync = true
1034 for arg, i in args
1035 if skip
1036 skip--
1037 continue
1038 switch arg
1039 when '-h', '--help'
1040 help()
1041 when '-m', '--margin'
1042 Drawing.keepMargins = true
1043 when '--hidden'
1044 Drawings.keepHidden = true
1045 when '--tw', '--tile-width'
1046 skip = 1
1047 arg = parseFloat args[i+1]
1048 if arg
1049 Symbol.forceWidth = arg
1050 else
1051 console.warn "Invalid argument to --tile-width: #{args[i+1]}"
1052 when '--th', '--tile-height'
1053 skip = 1
1054 arg = parseFloat args[i+1]
1055 if arg
1056 Symbol.forceHeight = arg
1057 else
1058 console.warn "Invalid argument to --tile-height: #{args[i+1]}"
1059 when '-p', '--pdf'
1060 formats.push 'pdf'
1061 when '-P', '--png'
1062 formats.push 'png'
1063 when '-t', '--tex'
1064 Symbol.texText = true
1065 when '--no-sanitize'
1066 sanitize = false
1067 when '-j', '--jobs'
1068 skip = 1
1069 arg = parseInt args[i+1]
1070 if arg
1071 jobs = new require('async-limiter') concurrency: arg
1072 sync = false
1073 else
1074 console.warn "Invalid argument to --jobs: #{args[i+1]}"
1075 else
1076 files++
1077 console.log '*', arg
1078 input = Input.recognize arg
1079 if input instanceof Mapping
1080 mappings.push input
1081 else if input instanceof Drawing or input instanceof Drawings
1082 filenames = input.writeSVG mappings
1083 input.writeTeX mappings if Symbol.texText
1084 for format in formats
1085 if typeof filenames == 'string'
1086 jobs.push convertSVG format, filenames, sync
1087 else
1088 for filename in filenames
1089 jobs.push convertSVG format, filename, sync
1090 unless files
1091 console.log 'Not enough filename arguments'
1092 help()
1093
1094exports = {Symbol, StaticSymbol, DynamicSymbol, unrecognizedSymbol,
1095 Mapping, ASCIIMapping, JSMapping, CoffeeMapping,
1096 Drawing, ASCIIDrawing, DSVDrawing, SSVDrawing, CSVDrawing, TSVDrawing,
1097 Drawings, XLSXDrawings,
1098 Input, Mappings, Context, SVGTilerException, SVGNS, XLINKNS, main}
1099module?.exports ?= exports
1100window?.svgtiler ?= exports
1101
1102unless window?
1103 main()
1104
1105`}).call(this)`