nswtopo 2.0.0.pre.beta1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/COPYING +674 -0
- data/bin/nswtopo +430 -0
- data/docs/README.md +78 -0
- data/docs/add.md +49 -0
- data/docs/config.md +24 -0
- data/docs/contours.md +37 -0
- data/docs/controls.md +9 -0
- data/docs/declination.md +15 -0
- data/docs/delete.md +15 -0
- data/docs/grid.md +5 -0
- data/docs/info.md +5 -0
- data/docs/init.md +38 -0
- data/docs/layers.md +11 -0
- data/docs/overlay.md +37 -0
- data/docs/relief.md +22 -0
- data/docs/render.md +43 -0
- data/docs/spot-heights.md +23 -0
- data/lib/nswtopo/archive.rb +93 -0
- data/lib/nswtopo/avl_tree.rb +128 -0
- data/lib/nswtopo/config.rb +73 -0
- data/lib/nswtopo/dither.rb +31 -0
- data/lib/nswtopo/font/chrome.rb +59 -0
- data/lib/nswtopo/font/generic.rb +25 -0
- data/lib/nswtopo/font.rb +43 -0
- data/lib/nswtopo/formats/kmz.rb +149 -0
- data/lib/nswtopo/formats/mbtiles.rb +64 -0
- data/lib/nswtopo/formats/pdf.rb +31 -0
- data/lib/nswtopo/formats/svg.rb +69 -0
- data/lib/nswtopo/formats/svgz.rb +13 -0
- data/lib/nswtopo/formats/zip.rb +40 -0
- data/lib/nswtopo/formats.rb +76 -0
- data/lib/nswtopo/geometry/overlap.rb +78 -0
- data/lib/nswtopo/geometry/r_tree.rb +47 -0
- data/lib/nswtopo/geometry/segment.rb +27 -0
- data/lib/nswtopo/geometry/straight_skeleton/collapse.rb +21 -0
- data/lib/nswtopo/geometry/straight_skeleton/interior_node.rb +17 -0
- data/lib/nswtopo/geometry/straight_skeleton/node.rb +50 -0
- data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +295 -0
- data/lib/nswtopo/geometry/straight_skeleton/split.rb +33 -0
- data/lib/nswtopo/geometry/straight_skeleton/vertex.rb +9 -0
- data/lib/nswtopo/geometry/straight_skeleton.rb +6 -0
- data/lib/nswtopo/geometry/vector.rb +91 -0
- data/lib/nswtopo/geometry/vector_sequence.rb +179 -0
- data/lib/nswtopo/geometry.rb +8 -0
- data/lib/nswtopo/gis/arcgis_server/connection.rb +52 -0
- data/lib/nswtopo/gis/arcgis_server.rb +155 -0
- data/lib/nswtopo/gis/dem.rb +70 -0
- data/lib/nswtopo/gis/esri_hdr.rb +77 -0
- data/lib/nswtopo/gis/gdal_glob.rb +41 -0
- data/lib/nswtopo/gis/geojson/collection.rb +94 -0
- data/lib/nswtopo/gis/geojson/line_string.rb +11 -0
- data/lib/nswtopo/gis/geojson/multi_line_string.rb +63 -0
- data/lib/nswtopo/gis/geojson/multi_point.rb +12 -0
- data/lib/nswtopo/gis/geojson/multi_polygon.rb +167 -0
- data/lib/nswtopo/gis/geojson/point.rb +9 -0
- data/lib/nswtopo/gis/geojson/polygon.rb +11 -0
- data/lib/nswtopo/gis/geojson.rb +89 -0
- data/lib/nswtopo/gis/gps/gpx.rb +22 -0
- data/lib/nswtopo/gis/gps/kml.rb +66 -0
- data/lib/nswtopo/gis/gps.rb +20 -0
- data/lib/nswtopo/gis/projection.rb +56 -0
- data/lib/nswtopo/gis/shapefile.rb +24 -0
- data/lib/nswtopo/gis/world_file.rb +19 -0
- data/lib/nswtopo/gis.rb +9 -0
- data/lib/nswtopo/help_formatter.rb +59 -0
- data/lib/nswtopo/helpers/array.rb +30 -0
- data/lib/nswtopo/helpers/colour.rb +176 -0
- data/lib/nswtopo/helpers/concurrently.rb +27 -0
- data/lib/nswtopo/helpers/dir.rb +7 -0
- data/lib/nswtopo/helpers/hash.rb +15 -0
- data/lib/nswtopo/helpers/tar_writer.rb +11 -0
- data/lib/nswtopo/helpers.rb +6 -0
- data/lib/nswtopo/layer/arcgis_raster.rb +73 -0
- data/lib/nswtopo/layer/contour.rb +233 -0
- data/lib/nswtopo/layer/control.rb +94 -0
- data/lib/nswtopo/layer/declination.rb +53 -0
- data/lib/nswtopo/layer/feature.rb +87 -0
- data/lib/nswtopo/layer/grid.rb +120 -0
- data/lib/nswtopo/layer/import.rb +25 -0
- data/lib/nswtopo/layer/labels/fence.rb +20 -0
- data/lib/nswtopo/layer/labels.rb +630 -0
- data/lib/nswtopo/layer/overlay.rb +53 -0
- data/lib/nswtopo/layer/raster.rb +63 -0
- data/lib/nswtopo/layer/relief.rb +143 -0
- data/lib/nswtopo/layer/spot.rb +171 -0
- data/lib/nswtopo/layer/vector.rb +263 -0
- data/lib/nswtopo/layer/vegetation.rb +73 -0
- data/lib/nswtopo/layer.rb +78 -0
- data/lib/nswtopo/log.rb +28 -0
- data/lib/nswtopo/map.rb +296 -0
- data/lib/nswtopo/os.rb +75 -0
- data/lib/nswtopo/safely.rb +13 -0
- data/lib/nswtopo/version.rb +4 -0
- data/lib/nswtopo/zip.rb +15 -0
- data/lib/nswtopo.rb +249 -0
- 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
|
data/lib/nswtopo/font.rb
ADDED
@@ -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
|