1 | `#!/usr/bin/env node
|
2 | (function() {
|
3 | `
|
4 | unless 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'
|
15 | else
|
16 | DOMParser = window.DOMParser
|
17 | domImplementation = document.implementation
|
18 | path =
|
19 | extname: (x) -> /\.[^/]+$/.exec(x)[0]
|
20 | dirname: (x) -> /[^]*\/|/.exec(x)[0]
|
21 |
|
22 | SVGNS = 'http://www.w3.org/2000/svg'
|
23 | XLINKNS = 'http://www.w3.org/1999/xlink'
|
24 |
|
25 | splitIntoLines = (data) ->
|
26 | data.replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
27 | whitespace = /[\s\uFEFF\xA0]+/
|
28 |
|
29 | extensionOf = (filename) -> path.extname(filename).toLowerCase()
|
30 |
|
31 | class SVGTilerException
|
32 | constructor: (@message) ->
|
33 | toString: ->
|
34 | "svgtiler: #{@message}"
|
35 |
|
36 | overflowBox = (xml) ->
|
37 | if xml.documentElement.hasAttribute 'overflowBox'
|
38 | xml.documentElement.getAttribute('overflowBox').split /\s*,?\s+/
|
39 | .map parseFloat
|
40 | else
|
41 | null
|
42 |
|
43 | parseNum = (x) ->
|
44 | parsed = parseFloat x
|
45 | if isNaN parsed
|
46 | null
|
47 | else
|
48 | parsed
|
49 |
|
50 | svgBBox = (xml) ->
|
51 |
|
52 |
|
53 |
|
54 |
|
55 |
|
56 |
|
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 |
|
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)
|
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 |
|
121 | isAuto = (xml, prop) ->
|
122 | xml.documentElement.hasAttribute(prop) and
|
123 | /^\s*auto\s*$/i.test xml.documentElement.getAttribute prop
|
124 |
|
125 | attributeOrStyle = (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 |
|
134 | zIndex = (node) ->
|
135 |
|
136 |
|
137 |
|
138 |
|
139 |
|
140 | z = parseInt attributeOrStyle node, 'z-index'
|
141 | if isNaN z
|
142 | 0
|
143 | else
|
144 | z
|
145 |
|
146 | class Symbol
|
147 | @svgEncoding: 'utf8'
|
148 | @forceWidth: null
|
149 | @forceHeight: null
|
150 | @texText: false
|
151 |
|
152 | |
153 |
|
154 |
|
155 |
|
156 |
|
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 |
|
170 |
|
171 |
|
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() == ''
|
177 | svg: '<symbol viewBox="0 0 0 0"/>'
|
178 | else if data.indexOf('<') < 0
|
179 | if dirname?
|
180 | filename = path.join dirname, data
|
181 | else
|
182 | filename = data
|
183 | extension = extensionOf data
|
184 |
|
185 |
|
186 |
|
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 |
|
206 |
|
207 | escapeId = (key) ->
|
208 | |
209 |
|
210 |
|
211 |
|
212 |
|
213 |
|
214 |
|
215 |
|
216 |
|
217 |
|
218 |
|
219 |
|
220 |
|
221 |
|
222 |
|
223 |
|
224 |
|
225 |
|
226 |
|
227 |
|
228 |
|
229 |
|
230 |
|
231 |
|
232 | (encodeURIComponent key
|
233 | .replace /%/g, '$'
|
234 | ) or '$BLANK$'
|
235 |
|
236 | zeroSizeReplacement = 1
|
237 |
|
238 | class StaticSymbol extends Symbol
|
239 | constructor: (@key, options) ->
|
240 | super()
|
241 | for own key, value of options
|
242 | @[key] = value
|
243 |
|
244 |
|
245 |
|
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:
|
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 |
|
267 |
|
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 |
|
279 |
|
280 |
|
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 |
|
301 |
|
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 |
|
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: -> @
|
325 | usesContext: false
|
326 |
|
327 | class DynamicSymbol extends Symbol
|
328 | @all: []
|
329 | constructor: (@key, @func, @dirname) ->
|
330 | super()
|
331 | @versions = {}
|
332 | @nversions = 0
|
333 | @constructor.all.push @
|
334 | @resetAll: ->
|
335 |
|
336 |
|
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 |
|
345 |
|
346 |
|
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 |
|
357 |
|
358 |
|
359 | unrecognizedSymbol = 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 | '''
|
365 | unrecognizedSymbol.id = -> '$UNRECOGNIZED$'
|
366 |
|
367 | class Input
|
368 | @encoding: 'utf8'
|
369 | @parseFile: (filename, filedata) ->
|
370 |
|
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 |
|
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 |
|
385 | class 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()
|
400 | if key of @map
|
401 | @map[key]
|
402 | else if @function?
|
403 |
|
404 |
|
405 |
|
406 | value = @function key
|
407 | if value?
|
408 | @map[key] = Symbol.parse key, value
|
409 | else
|
410 | value
|
411 | else
|
412 | undefined
|
413 |
|
414 | class 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 |
|
425 | key = ''
|
426 | else
|
427 |
|
428 | key = line[0]
|
429 | else
|
430 | key = line[...separator.index]
|
431 | map[key] = line[separator.index + separator[0].length..]
|
432 | @load map
|
433 |
|
434 | class 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 |
|
451 |
|
452 |
|
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 |
|
462 | class 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 |
|
467 | super.parse require('coffeescript').compile data,
|
468 | bare: true
|
469 | filename: @filename
|
470 | sourceFiles: [@filename]
|
471 | inlineMap: true
|
472 |
|
473 |
|
474 |
|
475 |
|
476 |
|
477 |
|
478 |
|
479 |
|
480 |
|
481 |
|
482 |
|
483 |
|
484 |
|
485 |
|
486 |
|
487 |
|
488 |
|
489 |
|
490 | class 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 |
|
501 | blankCells =
|
502 | '': true
|
503 | ' ': true
|
504 |
|
505 | allBlank = (list) ->
|
506 | for x in list
|
507 | if x? and x not of blankCells
|
508 | return false
|
509 | true
|
510 |
|
511 | class Drawing extends Input
|
512 | load: (data) ->
|
513 |
|
514 | data = for row in data
|
515 | for cell in row
|
516 | cell
|
517 | unless Drawing.keepMargins
|
518 |
|
519 | while data.length > 0 and allBlank data[0]
|
520 | data.shift()
|
521 |
|
522 | while data.length > 0 and allBlank data[data.length-1]
|
523 | data.pop()
|
524 | if data.length > 0
|
525 |
|
526 | while allBlank (row[0] for row in data)
|
527 | for row in data
|
528 | row.shift()
|
529 |
|
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 |
|
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 |
|
552 |
|
553 |
|
554 |
|
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 |
|
562 |
|
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 |
|
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 |
|
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 |
|
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 |
|
605 | if symbol.viewBox?
|
606 | node.setAttribute 'viewBox', symbol.viewBox
|
607 |
|
608 | viewBox = [0, 0, 0, 0]
|
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 |
|
640 |
|
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 |
|
657 | viewBox[2] = viewBox[2] - viewBox[0]
|
658 | viewBox[3] = viewBox[3] - viewBox[1]
|
659 |
|
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 |
|
672 |
|
673 | out = out.replace /\sxmlns:xlink=""/g, ''
|
674 | out = prettyXML out,
|
675 | newline: '\n'
|
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 |
|
683 | filename = path.parse filename
|
684 | basename = filename.base[...-filename.ext.length]
|
685 |
|
686 |
|
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
|
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
|
756 | wrap = '\\llap{'
|
757 |
|
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 | """, ''
|
763 | lines.join '\n'
|
764 | writeTeX: (filename) ->
|
765 | |
766 |
|
767 |
|
768 |
|
769 |
|
770 |
|
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 |
|
783 | class 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 |
|
791 | class DSVDrawing extends Drawing
|
792 | parse: (data) ->
|
793 |
|
794 | if data[-2..] == '\r\n'
|
795 | data = data[...-2]
|
796 | else if data[-1..] in ['\r', '\n']
|
797 | data = data[...-1]
|
798 |
|
799 | @load require('csv-parse/lib/sync') data,
|
800 | delimiter: @constructor.delimiter
|
801 | relax_column_count: true
|
802 |
|
803 | class SSVDrawing extends DSVDrawing
|
804 | @title: "Space-delimiter drawing (one word per symbol)"
|
805 | @delimiter: ' '
|
806 | parse: (data) ->
|
807 |
|
808 | super data.replace /[ \t\f\v]+/g, ' '
|
809 |
|
810 | class CSVDrawing extends DSVDrawing
|
811 | @title: "Comma-separated drawing (spreadsheet export)"
|
812 | @delimiter: ','
|
813 |
|
814 | class TSVDrawing extends DSVDrawing
|
815 | @title: "Tab-separated drawing (spreadsheet export)"
|
816 | @delimiter: '\t'
|
817 |
|
818 | class 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
|
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 |
|
845 | class 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 |
|
852 | @load (
|
853 | for sheetInfo in workbook.Workbook.Sheets
|
854 | subname = sheetInfo.name
|
855 | sheet = workbook.Sheets[subname]
|
856 |
|
857 |
|
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 |
|
869 | class 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 |
|
889 | extensionMap =
|
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 |
|
900 | '.xlsx': XLSXDrawings
|
901 | '.xlsm': XLSXDrawings
|
902 | '.xlsb': XLSXDrawings
|
903 | '.xls': XLSXDrawings
|
904 | '.ods': XLSXDrawings
|
905 | '.fods': XLSXDrawings
|
906 | '.dif': XLSXDrawings
|
907 | '.prn': XLSXDrawings
|
908 | '.dbf': XLSXDrawings
|
909 |
|
910 | sanitize = true
|
911 | bufferSize = 16*1024
|
912 |
|
913 | postprocess = (format, filename) ->
|
914 | return unless sanitize
|
915 | try
|
916 | switch format
|
917 | when 'pdf'
|
918 |
|
919 |
|
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'
|
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 |
|
933 | convertSVG = (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 |
|
942 |
|
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 |
|
954 |
|
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 |
|
965 |
|
966 |
|
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 |
|
983 | help = ->
|
984 | console.log """
|
985 | svgtiler #{svgtiler.version ? "(web)"}
|
986 | Usage: #{process.argv[1]} (...options and filenames...)
|
987 | Documentation: https://github.com/edemaine/svgtiler
|
988 |
|
989 | Optional 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 |
|
1003 | Filename 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 |
|
1013 | SYMBOL 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 |
|
1025 | process.exit()
|
1026 |
|
1027 | main = ->
|
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 |
|
1094 | exports = {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}
|
1099 | module?.exports ?= exports
|
1100 | window?.svgtiler ?= exports
|
1101 |
|
1102 | unless window?
|
1103 | main()
|
1104 |
|
1105 | `}).call(this)`
|