nswtopo 2.0.0.pre.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/COPYING +674 -0
  3. data/bin/nswtopo +430 -0
  4. data/docs/README.md +78 -0
  5. data/docs/add.md +49 -0
  6. data/docs/config.md +24 -0
  7. data/docs/contours.md +37 -0
  8. data/docs/controls.md +9 -0
  9. data/docs/declination.md +15 -0
  10. data/docs/delete.md +15 -0
  11. data/docs/grid.md +5 -0
  12. data/docs/info.md +5 -0
  13. data/docs/init.md +38 -0
  14. data/docs/layers.md +11 -0
  15. data/docs/overlay.md +37 -0
  16. data/docs/relief.md +22 -0
  17. data/docs/render.md +43 -0
  18. data/docs/spot-heights.md +23 -0
  19. data/lib/nswtopo/archive.rb +93 -0
  20. data/lib/nswtopo/avl_tree.rb +128 -0
  21. data/lib/nswtopo/config.rb +73 -0
  22. data/lib/nswtopo/dither.rb +31 -0
  23. data/lib/nswtopo/font/chrome.rb +59 -0
  24. data/lib/nswtopo/font/generic.rb +25 -0
  25. data/lib/nswtopo/font.rb +43 -0
  26. data/lib/nswtopo/formats/kmz.rb +149 -0
  27. data/lib/nswtopo/formats/mbtiles.rb +64 -0
  28. data/lib/nswtopo/formats/pdf.rb +31 -0
  29. data/lib/nswtopo/formats/svg.rb +69 -0
  30. data/lib/nswtopo/formats/svgz.rb +13 -0
  31. data/lib/nswtopo/formats/zip.rb +40 -0
  32. data/lib/nswtopo/formats.rb +76 -0
  33. data/lib/nswtopo/geometry/overlap.rb +78 -0
  34. data/lib/nswtopo/geometry/r_tree.rb +47 -0
  35. data/lib/nswtopo/geometry/segment.rb +27 -0
  36. data/lib/nswtopo/geometry/straight_skeleton/collapse.rb +21 -0
  37. data/lib/nswtopo/geometry/straight_skeleton/interior_node.rb +17 -0
  38. data/lib/nswtopo/geometry/straight_skeleton/node.rb +50 -0
  39. data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +295 -0
  40. data/lib/nswtopo/geometry/straight_skeleton/split.rb +33 -0
  41. data/lib/nswtopo/geometry/straight_skeleton/vertex.rb +9 -0
  42. data/lib/nswtopo/geometry/straight_skeleton.rb +6 -0
  43. data/lib/nswtopo/geometry/vector.rb +91 -0
  44. data/lib/nswtopo/geometry/vector_sequence.rb +179 -0
  45. data/lib/nswtopo/geometry.rb +8 -0
  46. data/lib/nswtopo/gis/arcgis_server/connection.rb +52 -0
  47. data/lib/nswtopo/gis/arcgis_server.rb +155 -0
  48. data/lib/nswtopo/gis/dem.rb +70 -0
  49. data/lib/nswtopo/gis/esri_hdr.rb +77 -0
  50. data/lib/nswtopo/gis/gdal_glob.rb +41 -0
  51. data/lib/nswtopo/gis/geojson/collection.rb +94 -0
  52. data/lib/nswtopo/gis/geojson/line_string.rb +11 -0
  53. data/lib/nswtopo/gis/geojson/multi_line_string.rb +63 -0
  54. data/lib/nswtopo/gis/geojson/multi_point.rb +12 -0
  55. data/lib/nswtopo/gis/geojson/multi_polygon.rb +167 -0
  56. data/lib/nswtopo/gis/geojson/point.rb +9 -0
  57. data/lib/nswtopo/gis/geojson/polygon.rb +11 -0
  58. data/lib/nswtopo/gis/geojson.rb +89 -0
  59. data/lib/nswtopo/gis/gps/gpx.rb +22 -0
  60. data/lib/nswtopo/gis/gps/kml.rb +66 -0
  61. data/lib/nswtopo/gis/gps.rb +20 -0
  62. data/lib/nswtopo/gis/projection.rb +56 -0
  63. data/lib/nswtopo/gis/shapefile.rb +24 -0
  64. data/lib/nswtopo/gis/world_file.rb +19 -0
  65. data/lib/nswtopo/gis.rb +9 -0
  66. data/lib/nswtopo/help_formatter.rb +59 -0
  67. data/lib/nswtopo/helpers/array.rb +30 -0
  68. data/lib/nswtopo/helpers/colour.rb +176 -0
  69. data/lib/nswtopo/helpers/concurrently.rb +27 -0
  70. data/lib/nswtopo/helpers/dir.rb +7 -0
  71. data/lib/nswtopo/helpers/hash.rb +15 -0
  72. data/lib/nswtopo/helpers/tar_writer.rb +11 -0
  73. data/lib/nswtopo/helpers.rb +6 -0
  74. data/lib/nswtopo/layer/arcgis_raster.rb +73 -0
  75. data/lib/nswtopo/layer/contour.rb +233 -0
  76. data/lib/nswtopo/layer/control.rb +94 -0
  77. data/lib/nswtopo/layer/declination.rb +53 -0
  78. data/lib/nswtopo/layer/feature.rb +87 -0
  79. data/lib/nswtopo/layer/grid.rb +120 -0
  80. data/lib/nswtopo/layer/import.rb +25 -0
  81. data/lib/nswtopo/layer/labels/fence.rb +20 -0
  82. data/lib/nswtopo/layer/labels.rb +630 -0
  83. data/lib/nswtopo/layer/overlay.rb +53 -0
  84. data/lib/nswtopo/layer/raster.rb +63 -0
  85. data/lib/nswtopo/layer/relief.rb +143 -0
  86. data/lib/nswtopo/layer/spot.rb +171 -0
  87. data/lib/nswtopo/layer/vector.rb +263 -0
  88. data/lib/nswtopo/layer/vegetation.rb +73 -0
  89. data/lib/nswtopo/layer.rb +78 -0
  90. data/lib/nswtopo/log.rb +28 -0
  91. data/lib/nswtopo/map.rb +296 -0
  92. data/lib/nswtopo/os.rb +75 -0
  93. data/lib/nswtopo/safely.rb +13 -0
  94. data/lib/nswtopo/version.rb +4 -0
  95. data/lib/nswtopo/zip.rb +15 -0
  96. data/lib/nswtopo.rb +249 -0
  97. metadata +142 -0
data/docs/grid.md ADDED
@@ -0,0 +1,5 @@
1
+ # Description
2
+
3
+ Add a UTM grid to the map with the *grid* command. Grid coordinates for northings and eastings are labelled along the map edges.
4
+
5
+ Used in a digital map, grid lines serve mostly to indicate scale, with one grid square traditionally representing a square kilometre on the ground. You can change the grid interval with the `--interval` option.
data/docs/info.md ADDED
@@ -0,0 +1,5 @@
1
+ # Description
2
+
3
+ Use the *info* command to show map metadata and layers. The name of each layer is reported along with summary information. The order of the layers reflect their positions in the map, with subsequent layers being composited over earlier layers.
4
+
5
+ Feature layers with no content are not shown unless the `--empty` option is passed.
data/docs/init.md ADDED
@@ -0,0 +1,38 @@
1
+ # Description
2
+
3
+ Use the *init* command to initialise a new map file. Options for the command allow you to specify the size, location and orientation of the map. This metadata will be stored in the map file, along with map data for the various layers you subsequently add.
4
+
5
+ By convention, use a `.tgz` file extension for your map file, since it's in a *gzipped tar* archive format.
6
+
7
+ # Setting Map Location
8
+
9
+ The easiest way to set bounds is with the `--bounds` option. Using *Google Earth*, mark out a polygon covering the area you want to map, save as a KML file, then run the command:
10
+
11
+ ```
12
+ $ nswtopo init --bounds bounds.kml map.tgz
13
+ scale: 1:25000
14
+ dimensions: 246mm × 314mm
15
+ extent: 6.2km × 7.9km
16
+ area: 48.4km²
17
+ rotation: 0.0°
18
+ ```
19
+
20
+ This creates a map file covering the marked area. Information about the map is displayed.
21
+
22
+ For your bounds, you can also use waypoints (e.g. map corners, rogaine controls) or a GPX file of a recorded track. An additional margin can be set with the `--margin` option. (If waypoints or tracks are used to specify the bounds, a 15mm margin will be applied by default.)
23
+
24
+ An alternative way to set the map bounds is to specify two or more GPS coordinates using the `--coords` option. Provide a list of longitude & latitude coordinate pairs (e.g. for opposing corners of the map).
25
+
26
+ # Map Orientation and Dimensions
27
+
28
+ Maps are north-oriented unless you request otherwise. The `--rotation` option will produce a map with a given rotation angle. Use the keyword `magnetic` to align the map with magnetic north. The keyword `auto` yields a map oriented so as to fit your bounds in the smallest possible area.
29
+
30
+ You can make a map with set dimensions by using the `--dimensions` option, providing a width and height for the map in millimetres. For example, to create an A4 map at a given location:
31
+
32
+ ```
33
+ $ nswtopo init --dimensions 210,297 --coords 148.387,-36.148 map.tgz
34
+ ```
35
+
36
+ # Map Scale
37
+
38
+ A 1:25000 scale is conventional and should work well for most purposes. Change to a smaller or larger scale using the `--scale` option.
data/docs/layers.md ADDED
@@ -0,0 +1,11 @@
1
+ # Description
2
+
3
+ The *layers* command displays a list of all the named layers currently installed for *nswtopo*.
4
+
5
+ Layers are installed separately to *nswtopo* itself:
6
+
7
+ ```
8
+ $ gem install nswtopo-layers
9
+ ```
10
+
11
+ Many layers are part of a higher collection. For example, adding *nsw/topographic* will include all the layers listed in the *nsw/topographic/\** hierarchy.
data/docs/overlay.md ADDED
@@ -0,0 +1,37 @@
1
+ # Description
2
+
3
+ Use the *overlay* command to overlay GPX and KML files on the map. You can add both tracks (paths) and areas (polygons). Tracks can be added from GPX files, typically recorded by a GPS device. Create KML files by using *Google Earth* to trace out paths and polygons in various colours and styles.
4
+
5
+ # Adding Styles
6
+
7
+ The simplest way to add styles your overlay is with Google Earth. When drawing paths and polygons, set their colours and line-widths in the `Style, Color` tab. (Google Earth line-widths are in pixels, about 0.25mm each.) These styles will be honoured when you subsequently add the KML file to your map.
8
+
9
+ You can also use options to change styles for overlay features:
10
+
11
+ * **opacity**: layer opacity
12
+ * **stroke**: line colour
13
+ * **stroke-width**: line thickness in millimetres
14
+ * **stroke-dasharray**: list of measurements representing a dash pattern
15
+ * **stroke-opacity**: opacity of line strokes
16
+ * **fill**: fill colour
17
+ * **fill-opacity**: opacity of fill colour
18
+
19
+ These options will override styles set in a KML file. There are also useful for GPX files, which do not include style information. Opacities are given as a value between 0 and 1. Colours can be either an *RGB triplet* (e.g. *#800080*), *web colour* name (e.g. *purple*) or *none*.
20
+
21
+ The `--stroke-dasharray` option is useful for display a track as a dashed line. For example, to add unmarked firetrails to a map in dashed orange:
22
+
23
+ ```
24
+ $ nswtopo overlay --stroke "#FF7518" --stroke-width 0.3 --stroke-dasharray 1.8,0.6 -s map.tgz tracks.gpx
25
+ ```
26
+
27
+ Layer- and fill-opacity is best used to mark translucent polygons on the map. For example, to render out-of-bounds areas on a map as translucent black:
28
+
29
+ ```
30
+ $ nswtopo overlay --stroke none --fill black --opacity 0.3 map.tgz oob.kml
31
+ ```
32
+
33
+ # Track Simplification
34
+
35
+ When importing GPS tracks, noise can produce unwanted irregularities or roughness. Some simplification is applied to GPX tracks to smooth out these artefacts and produce a better-looking track. Use the `--simplify` option to apply simplification to KML linestrings as well.
36
+
37
+ The default tolerance ensures that the track position will not be adjusted by more than 0.5 millimetres on the map. Tolerance can be adjusted by providing a `--tolerance` value.
data/docs/relief.md ADDED
@@ -0,0 +1,22 @@
1
+ # Description
2
+
3
+ The *relief* command generates shaded relief from a Digital Elevation Model (DEM). You can use any DEM in a planar projection, but a resolution of 30 metres or better is suggested.
4
+
5
+ # Obtaining the DEM
6
+ Use the *ELVIS* website [http://elevation.fsdf.org.au] to download DEM tiles for any NSW location. The NSW 2-metre and 5-metre tiles are ideal. 1-metre NSW and ACT tiles also work but are more detailed than necessary. (Do not download Geoscience Australia tiles or point-cloud data.)
7
+
8
+ DEM tiles from the ELVIS website are delivered as doubly-zipped files. It's not necessary to unzip the download, although unzipping the first level to a folder will improve processing time.
9
+
10
+ # Configuration
11
+
12
+ No configuration is needed to get good results from ELVIS data. Use the following options to adjust the layer's appearance, if desired:
13
+
14
+ * **resolution**: resolution for the DEM data; a lower value will reduce file size but yields a smoother effect
15
+ * **opacity**: overall layer opacity
16
+ * **altitude**: raking angle of the light from the horizon
17
+ * **azimuth**: azimuth angle of the light, clockwise from north; deviation from the 315° default can be counter-intuitive
18
+ * **sources**: number of light sources to use for multi-directional shading
19
+ * **yellow**: amount of yellow illumination to apply as a fraction of grey shading
20
+ * **factor**: vertical exaggeration factor
21
+
22
+ Opacity and exaggeration can both be used to adjust the subtly of the shading effect.
data/docs/render.md ADDED
@@ -0,0 +1,43 @@
1
+ # Description
2
+
3
+ Once you've added your layers, use the *render* command to create the map itself. Different output formats are available for various purposes.
4
+
5
+ Specify your output format either as a filename with appropriate extension, or just the format extension itself (in which case the map's file name will be used). You can create multiple outputs at once.
6
+
7
+ Depending on contents, creation of the map may take some time, particularly in the labelling step.
8
+
9
+ # Formats
10
+
11
+ The following formats are available:
12
+
13
+ * **svg**: the native vector format - viewable with modern web browsers and editable by *Inkscape*, *Adobe Illustrator* or a text editor
14
+ * **tif**: a standard raster format, including *GeoTIFF* metadata tags for georeferencing
15
+ * **pdf**: *Portable Document Format*, either in the original vector form, or as a raster by setting a resolution with `--ppi`
16
+ * **kmz**: for use with *Google Earth* (add as a network link for best results)
17
+ * **zip**: a tiled format used by the *Avenza Maps* mobile app
18
+ * **mbtiles**: a tiled format for use with mobile GPS apps including *Locus Map* and *Guru Maps*
19
+ * **svgz**: a compressed SVG format, viewable directly in some browsers
20
+ * **png**: the well-known *Portable Network Graphics* format
21
+ * **jpg**: the well-known *JPEG* format (not well-suited to maps)
22
+
23
+ # Output Resolution
24
+
25
+ Most of the output formats are *raster* (pixel-based) formats. Use the `--ppi` option to set a resolution for these formats, in pixels per inch (PPI). The choice of PPI is a tradeoff between image quality and file size. The default value of 300 is a good choice for most purposes. Consider a higher PPI when producing a map for printing.
26
+
27
+ For the *mbtiles* format, resolution values are fixed to zoom levels. The default maximum zoom level of 16 corresponds to around 260 PPI.
28
+
29
+ # Setting Up Chrome
30
+
31
+ To create any output format except SVG, you'll need to have *Google Chrome* installed. Chrome is used by *nswtopo* in headless mode to render the vector SVG format as a raster graphic. *Firefox* can also be used, although it does not render some effects correctly. (On MacOS and Linux, Chrome is also used to measure font metrics during labelling.)
32
+
33
+ Add the path for your Chrome executable to your *nswtopo* configuration as follows:
34
+
35
+ ```
36
+ $ nswtopo config --chrome "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
37
+ ```
38
+
39
+ # Miscellaneous
40
+
41
+ For raster formats, use the `--dither` option to create the raster in indexed colour mode. This can reduce file size. For best results, have the `pngquant` program available on your command line for the dithering process.
42
+
43
+ After generating your map in SVG format, you can add content outside of *nswtopo* using a vector graphics editor such as Inkscape. Use the `--external` option to render from the edited map, instead of the internal copy maintained by *nswtopo*.
@@ -0,0 +1,23 @@
1
+ # Description
2
+
3
+ Use the *spot-heights* command to generate spot heights directly from a Digital Elevation Model (DEM). This command is best used in conjunction with contours generated from the same DEM.
4
+
5
+ # Obtaining the DEM
6
+ Use the *ELVIS* website [http://elevation.fsdf.org.au] to download DEM tiles for any NSW location. The NSW 2-metre and 5-metre tiles are ideal. 1-metre NSW and ACT tiles also work but are more detailed than necessary. (Do not download Geoscience Australia tiles or point-cloud data.)
7
+
8
+ DEM tiles from the ELVIS website are delivered as doubly-zipped files. It's not necessary to unzip the download, although unzipping the first level to a folder will improve processing time.
9
+
10
+ # Spot Height Configuration
11
+ Use the `--spacing` option to determine the maximum density of spot heights on the map. The value in millimetres represents the minimum distance between any two spot heights.
12
+
13
+ Choose the amount of smoothing to apply to the DEM with the `--smooth` option. The value represents a smoothing radius in millimetres. For consistency, the same smoothing radius shoult be used for both contours and spot heights.
14
+
15
+ Use the `--prefer` option to favour `knolls` or `saddles` when selecting spot locations. No preference is taken by default.
16
+
17
+ # Layer Position
18
+
19
+ Use an `--after`, `--before` or `--replace` option to insert the spot heights in an appropriate layer position. You will most likely want to replace an existing spot heights layer:
20
+
21
+ ```
22
+ $ nswtopo contours --replace nsw.topographic.spot-heights map.tgz DATA_25994.zip
23
+ ```
@@ -0,0 +1,93 @@
1
+ module NSWTopo
2
+ class Archive
3
+ extend Safely
4
+
5
+ def initialize(path, tar_in)
6
+ @tar_in, @basename, @entries = tar_in, path.basename(".tgz").basename(".tar.gz").to_s, Hash[]
7
+ end
8
+ attr_reader :basename
9
+
10
+ def delete(filename)
11
+ @entries[filename] = nil
12
+ end
13
+
14
+ def write(filename, content)
15
+ io = StringIO.new content
16
+ header = Gem::Package::TarHeader.new name: filename, size: io.size, prefix: "", mode: 0o0644, mtime: Time.now
17
+ @entries[filename] = Gem::Package::TarReader::Entry.new header, io
18
+ end
19
+
20
+ def mtime(filename)
21
+ header = @entries.key?(filename) ? @entries[filename]&.header : @tar_in.seek(filename, &:header)
22
+ Time.at header.mtime if header
23
+ end
24
+
25
+ def read(filename)
26
+ @entries.key?(filename) ? @entries[filename]&.read : @tar_in.seek(filename, &:read)
27
+ ensure
28
+ @entries[filename]&.rewind
29
+ end
30
+
31
+ def uptodate?(depender, *dependees)
32
+ return unless mtime(depender)
33
+ dependees.all? do |dependee|
34
+ mtimes = [depender, dependee].map(&method(:mtime))
35
+ mtimes.all? && mtimes.inject(&:>=)
36
+ end
37
+ end
38
+
39
+ def each(&block)
40
+ @tar_in.each do |entry|
41
+ yield entry unless @entries.key? entry.full_name
42
+ end
43
+ @entries.each do |filename, entry|
44
+ yield entry if entry
45
+ end
46
+ end
47
+
48
+ def changed?
49
+ return true if @entries.values.any?
50
+ @entries.keys.any? do |filename|
51
+ @tar_in.seek(filename, &:itself)
52
+ end
53
+ end
54
+
55
+ def self.open(out_path, in_path = nil, &block)
56
+ buffer, reader = StringIO.new, in_path ? Zlib::GzipReader : StringIO
57
+
58
+ reader.open(*in_path) do |input|
59
+ if in_path
60
+ version = input.comment.to_s[/^nswtopo (.+)$/, 1]
61
+ raise "unrecognised map file: %s" % in_path unless version
62
+ comparison = version.split(?.).map(&:to_i) <=> MIN_VERSION.split(?.).map(&:to_i)
63
+ raise "map file too old: version %s, minimum %s required" % [version, MIN_VERSION] unless comparison >= 0
64
+ end
65
+ Gem::Package::TarReader.new(input) do |tar_in|
66
+ archive = new(out_path, tar_in).tap(&block)
67
+ Gem::Package::TarWriter.new(buffer) do |tar_out|
68
+ archive.each &tar_out.method(:add_entry)
69
+ end if archive.changed?
70
+ end
71
+ end
72
+
73
+ Dir.mktmppath do |temp_dir|
74
+ log_update "nswtopo: saving map..."
75
+ temp_path = temp_dir / "temp.tgz"
76
+ Zlib::GzipWriter.open temp_path, Zlib::BEST_COMPRESSION do |gzip|
77
+ gzip.comment = "nswtopo %s" % VERSION
78
+ gzip.write buffer.string
79
+ rescue Interrupt
80
+ log_update "nswtopo: interrupted, please wait..."
81
+ raise
82
+ end
83
+ safely "saving map file, please wait..." do
84
+ FileUtils.cp temp_path, out_path
85
+ end
86
+ log_success "map saved"
87
+ end unless buffer.size.zero?
88
+
89
+ rescue Zlib::GzipFile::Error
90
+ raise "unrecognised map file: %s" % in_path
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,128 @@
1
+ class AVLTree
2
+ include Enumerable
3
+ attr_accessor :value, :left, :right, :height
4
+
5
+ def initialize(&block)
6
+ empty!
7
+ end
8
+
9
+ def empty?
10
+ @value.nil?
11
+ end
12
+
13
+ def empty!
14
+ @value, @left, @right, @height = nil, nil, nil, 0
15
+ end
16
+
17
+ def leaf?
18
+ [@left, @right].all?(&:empty?)
19
+ end
20
+
21
+ def replace_with(node)
22
+ @value, @left, @right, @height = node.value, node.left, node.right, node.height
23
+ end
24
+
25
+ def balance
26
+ empty? ? 0 : @left.height - @right.height
27
+ end
28
+
29
+ def update_height
30
+ @height = empty? ? 0 : [@left, @right].map(&:height).max + 1
31
+ end
32
+
33
+ def first_node
34
+ empty? || @left.empty? ? self : @left.first_node
35
+ end
36
+
37
+ def last_node
38
+ empty? || @right.empty? ? self : @right.last_node
39
+ end
40
+
41
+ def ancestors(node)
42
+ node.empty? ? [] : case [@value, @value.object_id] <=> [node.value, node.value.object_id]
43
+ when +1 then [*@left.ancestors(node), self]
44
+ when 0 then []
45
+ when -1 then [*@right.ancestors(node), self]
46
+ end
47
+ end
48
+
49
+ def rotate_left
50
+ a, b, c, v, @value = @left, @right.left, @right.right, @value, @right.value
51
+ @left = @right
52
+ @left.value, @left.left, @left.right, @right = v, a, b, c
53
+ [@left, self].each(&:update_height)
54
+ end
55
+
56
+ def rotate_right
57
+ a, b, c, v, @value = @left.left, @left.right, @right, @value, @left.value
58
+ @right = @left
59
+ @left.value, @left, @right.left, @right.right = v, a, b, c
60
+ [@right, self].each(&:update_height)
61
+ end
62
+
63
+ def rebalance
64
+ update_height
65
+ case balance
66
+ when +2
67
+ @left.rotate_left if @left.balance == -1
68
+ rotate_right
69
+ when -2
70
+ @right.rotate_right if @right.balance == 1
71
+ rotate_left
72
+ end unless empty?
73
+ end
74
+
75
+ def insert(value)
76
+ if empty?
77
+ @value, @left, @right = value, AVLTree.new, AVLTree.new
78
+ else
79
+ case [@value, @value.object_id] <=> [value, value.object_id]
80
+ when +1 then @left.insert value
81
+ when 0 then @value = value
82
+ when -1 then @right.insert value
83
+ end
84
+ end
85
+ rebalance
86
+ end
87
+ alias << insert
88
+
89
+ def merge(values)
90
+ values.each { |value| insert value }
91
+ self
92
+ end
93
+
94
+ def delete(value)
95
+ case [@value, @value.object_id] <=> [value, value.object_id]
96
+ when +1 then @left.delete value
97
+ when 0
98
+ @value.tap do
99
+ case
100
+ when leaf? then empty!
101
+ when @left.empty?
102
+ node = @right.first_node
103
+ @value = node.value
104
+ node.replace_with node.right
105
+ ancestors(node).each(&:rebalance) unless node.empty?
106
+ else
107
+ node = @left.last_node
108
+ @value = node.value
109
+ node.replace_with node.left
110
+ ancestors(node).each(&:rebalance) unless node.empty?
111
+ end
112
+ end
113
+ when -1 then @right.delete value
114
+ end.tap { rebalance } unless empty?
115
+ end
116
+
117
+ def pop
118
+ delete first_node.value unless empty?
119
+ end
120
+
121
+ def each(&block)
122
+ unless empty?
123
+ @left.each &block
124
+ block.call @value
125
+ @right.each &block
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,73 @@
1
+ module NSWTopo
2
+ module Config
3
+ include Log
4
+ singleton_class.attr_writer :extra_path
5
+
6
+ def self.method_missing(symbol, *args, &block)
7
+ extend(self).init
8
+ singleton_class.remove_method :method_missing
9
+ send symbol, *args, &block
10
+ end
11
+
12
+ def init
13
+ candidates = []
14
+ %w[XDG_CONFIG_HOME APPDATA].each do |key|
15
+ candidates << [ENV.fetch(key), "nswtopo"]
16
+ rescue KeyError
17
+ end
18
+ candidates << [Dir.home, "Library", "Application Support", "com.nswtopo"]
19
+ candidates << [Dir.home, ".config", "nswtopo"]
20
+ candidates << [Dir.home, ".nswtopo"]
21
+
22
+ @path = candidates.map do |base, *parts|
23
+ Pathname(base).join(*parts)
24
+ end.first do |dir|
25
+ dir.parent.directory?
26
+ end.join("nswtopo.cfg")
27
+
28
+ @config, extra = [@path, @extra_path].map do |path|
29
+ next Hash[] unless path&.file?
30
+ config = YAML.load(path.read)
31
+ Hash === config ? config : raise
32
+ rescue YAML::Exception, RuntimeError
33
+ log_warn "couldn't parse #{path} - ignoring"
34
+ Hash[]
35
+ end
36
+
37
+ @merged = @config.deep_merge extra
38
+ end
39
+
40
+ def store(*entries, key, value)
41
+ entries.inject(@config) do |config, entry|
42
+ config[entry] ||= {}
43
+ Hash === config[entry] ? config[entry] : raise("entry already taken: %s" % entry)
44
+ end.store key, value
45
+ end
46
+
47
+ def delete(*entries, key)
48
+ delete_recursive @config, *entries, key
49
+ end
50
+
51
+ def delete_recursive(config, *entries, key)
52
+ case
53
+ when entry = entries.shift
54
+ raise "no such entry: %s" % entry unless Hash === config[entry]
55
+ delete_recursive config[entry], *entries, key
56
+ config.delete entry if config[entry].empty?
57
+ when config.key?(key)
58
+ config.delete key
59
+ else
60
+ raise "no such entry: %s" % key
61
+ end
62
+ end
63
+
64
+ extend Forwardable
65
+ delegate %i[slice empty? [] fetch] => :@merged
66
+ def_delegator :@config, :to_yaml, :to_str
67
+
68
+ def save
69
+ @path.parent.mkpath
70
+ @path.write @config.to_yaml
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,31 @@
1
+ module NSWTopo
2
+ module Dither
3
+ def dither(*png_paths)
4
+ Enumerator.new do |yielder|
5
+ yielder << -> { OS.pngquant "--quiet", "--force", "--ext", ".png", "--speed", 1, "--nofs", *png_paths }
6
+ gimp_script = <<~EOF
7
+ (map
8
+ (lambda (path)
9
+ (let*
10
+ (
11
+ (image (car (gimp-file-load RUN-NONINTERACTIVE path path)))
12
+ (drawable (car (gimp-image-get-active-layer image)))
13
+ )
14
+ (gimp-image-convert-indexed image FSLOWBLEED-DITHER MAKE-PALETTE 256 FALSE FALSE "")
15
+ (gimp-file-save RUN-NONINTERACTIVE image drawable path path)
16
+ )
17
+ )
18
+ (list "#{png_paths.join ?\s}")
19
+ )
20
+ EOF
21
+ yielder << -> { OS.gimp "-c", "-d", "-f", "-i", "-b", gimp_script, "-b", "(gimp-quit TRUE)" }
22
+ yielder << -> { OS.mogrify "-type", "PaletteBilevelAlpha", "-dither", "Riemersma", *png_paths }
23
+ raise "pngquant, GIMP or ImageMagick required for dithering"
24
+ end.each do |dither|
25
+ dither.call
26
+ break
27
+ rescue OS::Missing
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,59 @@
1
+ module NSWTopo
2
+ module Font
3
+ module Chrome
4
+ ATTRIBUTES = %w[font-family font-variant font-style font-weight font-size letter-spacing word-spacing]
5
+
6
+ def command(string)
7
+ @input.puts string
8
+ lines, match = @output.expect(/(\{.*)\n/, 1)
9
+ response = JSON.parse match
10
+ raise "unexpected chrome error: %s" % response.dig("exceptionDetails", "exception", "description") if response["exceptionDetails"]
11
+ response.fetch("result").dig("value")
12
+ rescue TypeError, JSON::ParserError, KeyError
13
+ raise "unexpected chrome error"
14
+ end
15
+
16
+ def start_chrome
17
+ chrome_path = Config["chrome"]
18
+ svg = <<~XML
19
+ <?xml version='1.0' encoding='UTF-8'?>
20
+ <svg version='1.1' baseProfile='full' xmlns='http://www.w3.org/2000/svg' width='1mm' height='1mm' viewBox='0 0 1 1'>
21
+ <rect id='mm' width='1' height='1' stroke='none' />
22
+ <text id='text' />
23
+ </svg>
24
+ XML
25
+ @output, @input, @pid = PTY.spawn chrome_path, "--headless", "--disable-gpu", "--repl", "data:image/svg+xml;base64,#{Base64.encode64 svg}"
26
+ ObjectSpace.define_finalizer self, Proc.new { @input.puts "quit" }
27
+ command %Q[text = document.getElementById("text")]
28
+ @mm = command %Q[document.getElementById("mm").getBoundingClientRect().width]
29
+ end
30
+
31
+ def self.extended(instance)
32
+ instance.start_chrome
33
+ end
34
+
35
+ def validate(family)
36
+ return unless family
37
+ @families ||= Set[]
38
+ @families.add?(family) || return
39
+ command %Q[text.textContent="abcdefghijklmnopqrstuvwxyz"]
40
+ ["font-family:#{family}", nil].map do |style|
41
+ command %Q[text.setAttribute("style", "#{style}")]
42
+ command %Q[text.getBoundingClientRect().width]
43
+ end.inject(&:==) || return
44
+ log_neutral "font '#{family}' doesn't appear to be available"
45
+ end
46
+
47
+ def glyph_length(string, attributes)
48
+ style = attributes.slice(*ATTRIBUTES).map do |pair|
49
+ pair.join ?:
50
+ end.join(?;)
51
+ style << ";white-space:pre" if ?\s == string
52
+ validate attributes["font-family"]
53
+ command %Q[text.setAttribute("style", #{style.inspect})]
54
+ command %Q[text.textContent=#{string.inspect}]
55
+ command(%Q[text.getBoundingClientRect().width]) / @mm
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,25 @@
1
+ module NSWTopo
2
+ module Font
3
+ module Generic
4
+ WIDTHS = {
5
+ ?A => 0.732, ?B => 0.678, ?C => 0.682, ?D => 0.740, ?E => 0.583, ?F => 0.558, ?G => 0.728, ?H => 0.761, ?I => 0.256, ?J => 0.331, ?K => 0.641, ?L => 0.542, ?M => 0.843,
6
+ ?N => 0.740, ?O => 0.769, ?P => 0.649, ?Q => 0.769, ?R => 0.690, ?S => 0.620, ?T => 0.599, ?U => 0.728, ?V => 0.695, ?W => 1.108, ?X => 0.649, ?Y => 0.637, ?Z => 0.591,
7
+ ?a => 0.595, ?b => 0.595, ?c => 0.492, ?d => 0.595, ?e => 0.542, ?f => 0.335, ?g => 0.599, ?h => 0.583, ?i => 0.236, ?j => 0.289, ?k => 0.521, ?l => 0.236, ?m => 0.876,
8
+ ?n => 0.583, ?o => 0.571, ?p => 0.595, ?q => 0.595, ?r => 0.360, ?s => 0.492, ?t => 0.347, ?u => 0.575, ?v => 0.529, ?w => 0.864, ?x => 0.533, ?y => 0.529, ?z => 0.513,
9
+ ?0 => 0.595, ?1 => 0.595, ?2 => 0.595, ?3 => 0.595, ?4 => 0.595, ?5 => 0.595, ?6 => 0.595, ?7 => 0.595, ?8 => 0.595, ?9 => 0.595, ?! => 0.227, ?" => 0.422, ?# => 0.604,
10
+ ?$ => 0.595, ?% => 0.934, ?& => 0.678, ?' => 0.219, ?( => 0.314, ?) => 0.314, ?* => 0.451, ?+ => 0.595, ?, => 0.227, ?- => 0.426, ?. => 0.227, ?/ => 0.331, ?\\ => 0.327,
11
+ ?[ => 0.314, ?] => 0.314, ?^ => 0.595, ?_ => 0.500, ?` => 0.310, ?: => 0.227, ?; => 0.227, ?< => 0.595, ?= => 0.595, ?> => 0.595, ?? => 0.442, ?@ => 0.930, ?\s => 0.265,
12
+ }
13
+ WIDTHS.default = WIDTHS[?M]
14
+
15
+ def glyph_length(string, attributes)
16
+ font_size, letter_spacing, word_spacing = attributes.values_at("font-size", "letter-spacing", "word-spacing").map(&:to_f)
17
+ string.chars.each_cons(2).inject(WIDTHS[string[0]] * font_size) do |sum, pair|
18
+ next sum + WIDTHS[pair[1]] * font_size + letter_spacing unless pair[0] == ?\s
19
+ next sum + WIDTHS[pair[1]] * font_size + letter_spacing + word_spacing unless pair[1] == ?\s
20
+ sum
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,43 @@
1
+ require_relative 'font/generic'
2
+ require_relative 'font/chrome'
3
+
4
+ module NSWTopo
5
+ module Font
6
+ include Log
7
+ extend self
8
+
9
+ def glyph_length(*args)
10
+ chrome_path = Config["chrome"]
11
+ case
12
+ when !defined? PTY
13
+ self.extend Generic
14
+ when !chrome_path
15
+ log_warn "chrome browser not configured - using generic font measurements"
16
+ self.extend Generic
17
+ else
18
+ begin
19
+ stdout, stderr, status = Open3.capture3 chrome_path, "--version"
20
+ raise unless status.success?
21
+ self.extend Chrome
22
+ rescue Errno::ENOENT, RuntimeError
23
+ log_warn "couldn't run chrome - using generic font measurements"
24
+ self.extend Generic
25
+ end
26
+ end
27
+ glyph_length *args
28
+ end
29
+
30
+ def in_two(string, attributes)
31
+ words = string.split(string[?\n] || string[?/] || ?\s).map(&:strip)
32
+ (1...words.size).map do |index|
33
+ [words[0...index].join(?\s), words[index...words.size].join(?\s)]
34
+ end.map do |lines|
35
+ lines.map do |line|
36
+ [line, glyph_length(line, attributes)]
37
+ end
38
+ end.min_by do |lines_widths|
39
+ lines_widths.map(&:last).max
40
+ end || [[words[0], glyph_length(words[0], attributes)]]
41
+ end
42
+ end
43
+ end