Module: NSWTopo::Formats

Includes:
Log
Included in:
Map
Defined in:
lib/nswtopo/formats.rb,
lib/nswtopo/formats/kmz.rb,
lib/nswtopo/formats/pdf.rb,
lib/nswtopo/formats/svg.rb,
lib/nswtopo/formats/zip.rb,
lib/nswtopo/formats/svgz.rb,
lib/nswtopo/formats/mbtiles.rb

Defined Under Namespace

Modules: Kmz, Mbtiles

Constant Summary collapse

PPI =
300

Constants included from Log

Log::FAILURE, Log::NEUTRAL, Log::SUCCESS, Log::UPDATE

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Log

#log_abort, #log_neutral, #log_success, #log_update, #log_warn

Class Method Details

.===(ext) ⇒ Object



17
18
19
# File 'lib/nswtopo/formats.rb', line 17

def self.===(ext)
  extensions.any? ext
end

.extensionsObject



13
14
15
# File 'lib/nswtopo/formats.rb', line 13

def self.extensions
  instance_methods.grep(/^render_([a-z]+)/) { $1 }
end

Instance Method Details

#rasterise(png_path, external:, **options) ⇒ Object



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/nswtopo/formats.rb', line 33

def rasterise(png_path, external:, **options)
  Dir.mktmppath do |temp_dir|
    dimensions, ppi, resolution = raster_dimensions_at **options
    svg_path = temp_dir / "map.svg"
    src_path = temp_dir / "browser.svg"
    render_svg svg_path, external: external

    NSWTopo.with_browser do |browser_name, browser_path|
      megapixels = dimensions.inject(&:*) / 1024.0 / 1024.0
      log_update "%s: creating %i×%i (%.1fMpx) map raster at %i ppi"    % [browser_name, *dimensions, megapixels, options[:ppi]       ] if options[:ppi]
      log_update "%s: creating %i×%i (%.1fMpx) map raster at %.1f m/px" % [browser_name, *dimensions, megapixels, options[:resolution]] if options[:resolution]

      render = lambda do |width, height|
        args = case browser_name
        when "firefox"
          ["--window-size=#{width},#{height}", "-headless", "-screenshot", png_path.to_s]
        when "chrome"
          ["--window-size=#{width},#{height}", "--headless", "--screenshot=#{png_path}", "--disable-lcd-text", "--disable-extensions", "--hide-scrollbars", "--disable-gpu"]
        end
        FileUtils.rm png_path if png_path.exist?
        stdout, stderr, status = Open3.capture3 browser_path.to_s, *args, "file://#{src_path}"
        case browser_name
        when "firefox" then raise "couldn't rasterise map using firefox (ensure browser is closed)"
        when "chrome" then raise "couldn't rasterise map using chrome"
        end unless status.success? && png_path.file?
      end

      src_path.write %Q[<?xml version='1.0' encoding='UTF-8'?><svg version='1.1' baseProfile='full' xmlns='http://www.w3.org/2000/svg'></svg>]
      render.call 1000, 1000
      json = NSWTopo::OS.gdalinfo "-json", png_path
      scaling = JSON.parse(json)["size"][0] / 1000.0

      svg = %w[width height].inject(svg_path.read) do |svg, attribute|
        svg.sub(/#{attribute}='(.*?)mm'/) { %Q[#{attribute}='#{$1.to_f * ppi / 96.0 / scaling}mm'] }
      end
      src_path.write svg
      render.call *(dimensions / scaling).map(&:ceil)
    end

    OS.mogrify "+repage", "-crop", "#{dimensions.join ?x}+0+0", "-background", "white", "-flatten", "-alpha", "Off", "-units", "PixelsPerInch", "-density", ppi, "-define", "PNG:exclude-chunk=bkgd,itxt,ztxt,text,chrm", png_path
  end
end

#render_jpg(jpg_path, ppi: PPI, **options) ⇒ Object



29
30
31
# File 'lib/nswtopo/formats.rb', line 29

def render_jpg(jpg_path, ppi: PPI, **options)
  OS.gdal_translate "-of", "JPEG", "-co", "QUALITY=90", "-mo", "EXIF_XResolution=#{ppi}", "-mo", "EXIF_YResolution=#{ppi}", "-mo", "EXIF_ResolutionUnit=2", yield(ppi: ppi), jpg_path
end

#render_kmz(kmz_path, name:, ppi: PPI, **options) ⇒ Object



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/nswtopo/formats/kmz.rb', line 48

def render_kmz(kmz_path, name:, ppi: PPI, **options)
  metre_resolution = 0.0254 * @scale / ppi
  degree_resolution = 180.0 * metre_resolution / Math::PI / Kmz::EARTH_RADIUS

  wgs84_bounds = bounds(projection: Projection.wgs84)
  wgs84_dimensions = wgs84_bounds.transpose.difference / degree_resolution
  max_zoom = Math::log2(wgs84_dimensions.max).ceil - Math::log2(Kmz::TILE_SIZE).to_i
  topleft = [wgs84_bounds[0][0], wgs84_bounds[1][1]]
  png_path = yield(ppi: ppi)

  Dir.mktmppath do |temp_dir|
    pyramid = (0..max_zoom).map do |zoom|
      resolution = degree_resolution * 2**(max_zoom - zoom)
      degrees_per_tile = resolution * Kmz::TILE_SIZE
      counts = (wgs84_bounds.transpose.difference / degrees_per_tile).map(&:ceil)
      dimensions = counts.times Kmz::TILE_SIZE

      tfw_path = temp_dir / "#{name}.kmz.zoom.#{zoom}.tfw"
      tif_path = temp_dir / "#{name}.kmz.zoom.#{zoom}.tif"
      WorldFile.write topleft, resolution, 0, tfw_path
      OS.convert "-size", dimensions.join(?x), "canvas:none", "-type", "TrueColorMatte", "-depth", 8, tif_path
      OS.gdalwarp "-s_srs", @projection, "-t_srs", Projection.wgs84, "-r", "bilinear", "-dstalpha", png_path, tif_path

      indices_bounds = [topleft, counts, i[+ -]].transpose.map do |coord, count, increment|
        boundaries = (0..count).map { |index| coord.send increment, index * degrees_per_tile }
        [boundaries[0..-2], boundaries[1..-1]].transpose.map(&:sort)
      end.map do |tile_bounds|
        tile_bounds.each.with_index.to_a
      end.inject(:product).map(&:transpose).map do |tile_bounds, indices|
        { indices => tile_bounds }
      end.inject({}, &:merge)

      log_update "kmz: resizing image pyramid: %i%%" % (100 * (2**(zoom + 1) - 1) / (2**(max_zoom + 1) - 1))
      { zoom => [indices_bounds, tif_path] }
    end.inject({}, &:merge)

    kmz_dir = temp_dir.join("#{name}.kmz").tap(&:mkpath)
    pyramid.map do |zoom, (indices_bounds, tif_path)|
      zoom_dir = kmz_dir.join(zoom.to_s).tap(&:mkpath)
      indices_bounds.map do |indices, tile_bounds|
        index_dir = zoom_dir.join(indices.first.to_s).tap(&:mkpath)
        tile_kml_path = index_dir / "#{indices.last}.kml"
        tile_png_path = index_dir / "#{indices.last}.png"

        xml = REXML::Document.new
        xml << REXML::XMLDecl.new(1.0, "UTF-8")
        xml.add_element("kml", "xmlns" => "http://earth.google.com/kml/2.1").tap do |kml|
          kml.add_element("Document").tap do |document|
            document.add_element("Style").tap(&Kmz.style)
            document.add_element("Region").tap(&Kmz.region(tile_bounds, true))
            document.add_element("GroundOverlay").tap do |overlay|
              overlay.add_element("drawOrder").text = zoom
              overlay.add_element("Icon").add_element("href").text = tile_png_path.basename
              overlay.add_element("LatLonBox").tap(&Kmz.lat_lon_box(tile_bounds))
            end
            if zoom < max_zoom
              indices.map do |index|
                [2 * index, 2 * index + 1]
              end.inject(:product).select do |subindices|
                pyramid[zoom + 1][0][subindices]
              end.each do |subindices|
                path = "../../%i/%i/%i.kml" % [zoom + 1, *subindices]
                document.add_element("NetworkLink").tap(&Kmz.network_link(pyramid[zoom + 1][0][subindices], path))
              end
            end
          end
        end
        tile_kml_path.write xml

        crop = "%ix%i+%i+%s" % [Kmz::TILE_SIZE, Kmz::TILE_SIZE, indices[0] * Kmz::TILE_SIZE, indices[1] * Kmz::TILE_SIZE]
        [tif_path, "-quiet", "+repage", "-crop", crop, "+repage", "+dither", "-type", "PaletteBilevelMatte", "PNG8:#{tile_png_path}"]
      end
    end.flatten(1).tap do |tiles|
      log_update "kmz: creating %i tiles" % tiles.length
    end.each.concurrently do |args|
      OS.convert *args
    end

    xml = REXML::Document.new
    xml << REXML::XMLDecl.new(1.0, "UTF-8")
    xml.add_element("kml", "xmlns" => "http://earth.google.com/kml/2.1").tap do |kml|
      kml.add_element("Document").tap do |document|
        document.add_element("LookAt").tap do |look_at|
          range_x = @extents.first / 2.0 / Math::tan(Kmz::FOV) / Math::cos(Kmz::TILT)
          range_y = @extents.last / Math::cos(Kmz::FOV - Kmz::TILT) / 2 / (Math::tan(Kmz::FOV - Kmz::TILT) + Math::sin(Kmz::TILT))
          names_values = [%w[longitude latitude], wgs84_centre].transpose
          names_values << ["tilt", Kmz::TILT * 180.0 / Math::PI] << ["range", 1.2 * [range_x, range_y].max] << ["heading", @rotation]
          names_values.each { |name, value| look_at.add_element(name).text = value }
        end
        document.add_element("Name").text = name
        document.add_element("Style").tap(&Kmz.style)
        document.add_element("NetworkLink").tap(&Kmz.network_link(pyramid[0][0][[0,0]], "0/0/0.kml"))
      end
    end
    kml_path = kmz_dir / "doc.kml"
    kml_path.write xml

    zip kmz_dir, kmz_path
  end
end

#render_mbtiles(mbtiles_path, name:, zoom: Mbtiles::ZOOM, **options) ⇒ Object



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/nswtopo/formats/mbtiles.rb', line 7

def render_mbtiles(mbtiles_path, name:, zoom: Mbtiles::ZOOM, **options)
  raise "invalid zoom outside 10-19 range: #{zoom}" unless (10..19) === zoom

  web_mercator_bounds = bounds(projection: Projection.new("EPSG:3857"))
  wgs84_bounds = bounds(projection: Projection.wgs84)
  sql = "    CREATE TABLE metadata (name TEXT, value TEXT);\n    INSERT INTO metadata VALUES (\"name\", \"\#{name}\");\n    INSERT INTO metadata VALUES (\"type\", \"baselayer\");\n    INSERT INTO metadata VALUES (\"version\", \"1.1\");\n    INSERT INTO metadata VALUES (\"description\", \"\#{name}\");\n    INSERT INTO metadata VALUES (\"format\", \"png\");\n    INSERT INTO metadata VALUES (\"bounds\", \"\#{wgs84_bounds.transpose.flatten.join ?,}\");\n    CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB);\n  SQL\n\n  Dir.mktmppath do |temp_dir|\n    png_path = nil\n    zoom.downto(0).inject([]) do |levels, zoom|\n      resolution = Mbtiles::RESOLUTION / 2**zoom\n      indices, dimensions, topleft = web_mercator_bounds.map do |lower, upper|\n        ((lower - Mbtiles::ORIGIN) / resolution / Mbtiles::TILE_SIZE).floor ... ((upper - Mbtiles::ORIGIN) / resolution / Mbtiles::TILE_SIZE).ceil\n      end.map.with_index do |indices, axis|\n        [indices, (indices.last - indices.first) * Mbtiles::TILE_SIZE, Mbtiles::ORIGIN + (axis.zero? ? indices.first : indices.last) * Mbtiles::TILE_SIZE * resolution]\n      end.transpose\n      tile_path = temp_dir.join(\"\#{name}.mbtiles.\#{zoom}.%09d.png\").to_s\n      levels << [resolution, indices, dimensions, topleft, tile_path, zoom]\n      break levels if indices.map(&:size).all? { |size| size < 3 }\n      levels\n    end.tap do |(resolution, *, zoom), *|\n      png_path = yield(resolution: resolution)\n    end.tap do |levels|\n      log_update \"mbtiles: tiling for zoom levels %s\" % levels.map(&:last).minmax.uniq.join(?-)\n    end.each.concurrently do |resolution, indices, dimensions, topleft, tile_path, zoom|\n      tif_path, tfw_path = %w[tif tfw].map { |ext| temp_dir / \"\#{name}.mbtiles.\#{zoom}.\#{ext}\" }\n      WorldFile.write topleft, resolution, 0, tfw_path\n      OS.convert \"-size\", dimensions.join(?x), \"canvas:none\", \"-type\", \"TrueColorAlpha\", \"-depth\", 8, tif_path\n      OS.gdalwarp \"-s_srs\", @projection, \"-t_srs\", \"EPSG:3857\", \"-r\", \"lanczos\", \"-dstalpha\", png_path, tif_path\n      OS.convert tif_path, \"-quiet\", \"+repage\", \"-crop\", \"\#{Mbtiles::TILE_SIZE}x\#{Mbtiles::TILE_SIZE}\", tile_path\n    end.map do |resolution, indices, dimensions, topleft, tile_path, zoom|\n      indices[1].to_a.reverse.product(indices[0].to_a).map.with_index do |(row, col), index|\n        [tile_path % index, zoom, col, row]\n      end\n    end.flatten(1).each do |tile_path, zoom, col, row|\n      sql << %Q[INSERT INTO tiles VALUES (\#{zoom}, \#{col}, \#{row}, readfile(\"\#{tile_path}\"));\\n]\n    end.tap do |tiles|\n      log_update \"mbtiles: optimising %i tiles\" % tiles.length\n    end.map(&:first).each.concurrent_groups do |png_paths|\n      dither *png_paths\n    end\n    OS.sqlite3 mbtiles_path do |stdin|\n      stdin.puts sql\n      stdin.puts \".exit\"\n    end\n  end\nend\n"

#render_pdf(pdf_path, ppi: nil, external: nil, **options) ⇒ Object



3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# File 'lib/nswtopo/formats/pdf.rb', line 3

def render_pdf(pdf_path, ppi: nil, external: nil, **options)
  if ppi
    OS.gdal_translate "-a_srs", @projection, "-of", "PDF", "-co", "DPI=#{ppi}", "-co", "MARGIN=0", "-co", "CREATOR=nswtopo", "-co", "GEO_ENCODING=ISO32000", yield(ppi: ppi), pdf_path
  else
    Dir.mktmppath do |temp_dir|
      svg_path = temp_dir / "pdf-map.svg"
      render_svg svg_path, external: external
      xml = REXML::Document.new svg_path.read
      style = "@media print { @page { margin: 0 0 -1mm 0; size: %s %s; } }"
      svg = xml.elements["svg"]
      svg.add_element("style").text = style % svg.attributes.values_at("width", "height")
      svg_path.write xml

      FileUtils.rm pdf_path if pdf_path.exist?
      NSWTopo.with_browser do |browser_name, browser_path|
        args = case browser_name
        when "chrome"
          ["--headless", "--disable-gpu", "--print-to-pdf=#{pdf_path}"]
        when "firefox"
          raise "can't create vector PDF with firefox; use chrome or specify ppi for a raster PDF"
        end
        stdout, stderr, status = Open3.capture3 browser_path.to_s, *args, "file://#{svg_path}"
        raise "couldn't create PDF using %s" % browser_name unless status.success? && pdf_path.file?
      end
    end
  end
end

#render_png(png_path, ppi: PPI, dither: false, **options) ⇒ Object



21
22
23
# File 'lib/nswtopo/formats.rb', line 21

def render_png(png_path, ppi: PPI, dither: false, **options)
  FileUtils.cp yield(ppi: ppi, dither: dither), png_path
end

#render_svg(svg_path, external: nil, **options) ⇒ Object



3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/nswtopo/formats/svg.rb', line 3

def render_svg(svg_path, external: nil, **options)
  case
  when external
    raise "not a file: %s" % external unless external.file?
    begin
      svg = REXML::Document.new(external.read).elements["svg"]
      raise "not an SVG file: %s" % external unless svg
      desc = svg.elements["metadata/rdf:RDF/rdf:Description[@dc:creator='nswtopo']"]
      raise "not an nswtopo SVG file: %s" % external unless desc
    rescue REXML::ParseException
      raise "not an SVG file: %s" % external
    end
    FileUtils.cp external, svg_path

  when @archive.uptodate?("map.svg", "map.yml")
    svg_path.write @archive.read("map.svg")

  else
    width, height = extents.times(1000.0 / scale)
    xml = REXML::Document.new
    xml << REXML::XMLDecl.new(1.0, "utf-8")
    svg = xml.add_element "svg",
      "version" => 1.1,
      "baseProfile" => "full",
      "width"  => "#{width}mm",
      "height" => "#{height}mm",
      "viewBox" => "0 0 #{width} #{height}",
      "xmlns"          => "http://www.w3.org/2000/svg",
      "xmlns:xlink"    => "http://www.w3.org/1999/xlink",
      "xmlns:sodipodi" => "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd",
      "xmlns:inkscape" => "http://www.inkscape.org/namespaces/inkscape"

    meta = svg.add_element "metadata"
    rdf = meta.add_element "rdf:RDF",
      "xmlns:rdf" => "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
      "xmlns:dc"  => "http://purl.org/dc/elements/1.1/"
    rdf.add_element "rdf:Description",
      "dc:date" => Date.today.iso8601,
      "dc:format" => "image/svg+xml",
      "dc:creator" => "nswtopo"

    defs = svg.add_element "defs"
    svg.add_element "sodipodi:namedview", "borderlayer" => true
    svg.add_element "rect", "x" => 0, "y" => 0, "width" => width, "height" => height, "fill" => "white"

    labels = Layer.new "labels", self, Config.fetch("labels", {}).merge("type" => "Labels")
    layers.reject(&:empty?).each do |layer|
      next if Config["labelling"] == false
      labels.add layer if Vector === layer
    end.push(labels).each do |layer|
      log_update "compositing: #{layer.name}"
      group = svg.add_element "g", "id" => layer.name, "inkscape:groupmode" => "layer"
      layer.render group, defs, &labels.method(:add_fence)
    end

    until xml.elements.each("svg//g[not(*)]", &:remove).empty? do
    end

    string, formatter = String.new, REXML::Formatters::Pretty.new
    formatter.compact = true
    formatter.write xml, string
    write "map.svg", string
    svg_path.write string
  end
end

#render_svgz(svgz_path, external: nil, **options) ⇒ Object



3
4
5
6
7
8
9
10
11
# File 'lib/nswtopo/formats/svgz.rb', line 3

def render_svgz(svgz_path, external: nil, **options)
  Dir.mktmppath do |temp_dir|
    svg_path = temp_dir / "svgz-map.svg"
    render_svg svg_path, external: external
    Zlib::GzipWriter.open svgz_path do |gz|
      gz.write svg_path.binread
    end
  end
end

#render_tif(tif_path, ppi: PPI, dither: false, **options) ⇒ Object



25
26
27
# File 'lib/nswtopo/formats.rb', line 25

def render_tif(tif_path, ppi: PPI, dither: false, **options)
  OS.gdal_translate "-of", "GTiff", "-co", "COMPRESS=DEFLATE", "-co", "ZLEVEL=9", "-a_srs", @projection, yield(ppi: ppi, dither: dither), tif_path
end

#render_zip(zip_path, name:, ppi: PPI, **options) ⇒ Object



3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/nswtopo/formats/zip.rb', line 3

def render_zip(zip_path, name:, ppi: PPI, **options)
  Dir.mktmppath do |temp_dir|
    zip_dir = temp_dir.join("#{name}.avenza").tap(&:mkpath)
    tiles_dir = zip_dir.join("tiles").tap(&:mkpath)
    png_path = yield(ppi: ppi)
    top_left = bounding_box.coordinates[0][3]

    2.downto(0).map.with_index do |level, index|
      [level, index, ppi.to_f / 2**index]
    end.each.concurrently do |level, index, ppi|
      dimensions, ppi, resolution = raster_dimensions_at ppi: ppi
      img_path = index.zero? ? png_path : temp_dir / "#{name}.avenza.#{level}.png"
      tile_path = temp_dir.join("#{name}.avenza.tile.#{level}.%09d.png").to_s

      OS.convert png_path, "-filter", "Lanczos", "-resize", "%ix%i!" % dimensions, img_path unless img_path.exist?
      OS.convert img_path, "+repage", "-crop", "256x256", tile_path

      dimensions.reverse.map do |dimension|
        0.upto((dimension - 1) / 256).to_a
      end.inject(&:product).each.with_index do |(y, x), n|
        FileUtils.cp tile_path % n, tiles_dir / "#{level}x#{y}x#{x}.png"
      end
      zip_dir.join("#{name}.ref").open("w") do |file|
        file.puts @projection.wkt_simple
        file.puts WorldFile.geotransform(top_left, resolution, -@rotation).flatten.join(?,)
        file << dimensions.join(?,)
      end if index == 1
    end
    Pathname.glob(tiles_dir / "*.png").each.concurrent_groups do |tile_paths|
      dither *tile_paths
    end

    OS.convert png_path, "-thumbnail", "64x64", "-gravity", "center", "-background", "white", "-extent", "64x64", "-alpha", "Remove", "-type", "TrueColor", zip_dir / "thumb.png"
    zip zip_dir, zip_path
  end
end