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/gemf.rb,
lib/nswtopo/formats/svgz.rb,
lib/nswtopo/formats/mbtiles.rb

Defined Under Namespace

Modules: Kmz

Constant Summary collapse

PPI =
300
TILE =
1500
CHROME_ARGS =
%w[--force-gpu-mem-available-mb=4096]
CHROME_INSTANCES =
(ThreadPool::CORES / 4).clamp(1, 6)

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



23
24
25
# File 'lib/nswtopo/formats.rb', line 23

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

.extensionsObject



19
20
21
# File 'lib/nswtopo/formats.rb', line 19

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

Instance Method Details

#rasterise(png_path, background:, ppi: nil, resolution: nil) ⇒ Object



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
# File 'lib/nswtopo/formats.rb', line 59

def rasterise(png_path, background:, ppi: nil, resolution: nil)
  Dir.mktmppath do |temp_dir|
    svg_path = temp_dir / "map.svg"
    vrt_path = temp_dir / "map.vrt"
    render_svg svg_path, background: background

    case
    when ppi
      ppi_info = "%i ppi" % ppi
      mm_per_px = 25.4 / ppi
    when resolution
      ppi_info = "%.1f m/px" % resolution
      mm_per_px = to_mm(resolution)
    end

    viewport_size = [TILE * mm_per_px] * 2
    raster_size = @dimensions.map { |dimension| (dimension / mm_per_px).ceil }
    megapixels = raster_size.inject(&:*) / 1024.0 / 1024.0

    raster_info = "%i×%i (%.1fMpx) map raster at %s" % [*raster_size, megapixels, ppi_info]
    log_update "chrome: creating #{raster_info}"

    raster_size.map do |px|
      (0...px).step(TILE).map do |px|
        [px, px * mm_per_px]
      end
    end.inject(&:product).map(&:transpose).map do |raster_offset, viewport_offset|
      next raster_offset, viewport_offset, temp_dir.join("tile.%i.%i.png" % raster_offset)
    end.inject(ThreadPool.new(CHROME_INSTANCES), &:<<).in_groups do |*grid|
      NSWTopo::Chrome.with_browser "file://#{svg_path}", width: TILE, height: TILE, args: CHROME_ARGS do |browser|
        svg = browser.query_selector "svg"
        svg[:width], svg[:height] = nil, nil
        grid.each do |raster_offset, viewport_offset, tile_path|
          svg[:viewBox] = [*viewport_offset, *viewport_size].join(?\s)
          browser.screenshot tile_path
        end
      end
    end.map do |raster_offset, viewport_offset, tile_path|
      REXML::Document.new(OS.gdal_translate "-of", "VRT", tile_path, "/vsistdout/").tap do |vrt|
        vrt.elements.each("VRTDataset/VRTRasterBand/SimpleSource/DstRect") do |dst_rect|
          dst_rect.add_attributes "xOff" => raster_offset[0], "yOff" => raster_offset[1]
        end
      end
    end.inject do |vrt, tile_vrt|
      vrt.elements["VRTDataset/VRTRasterBand[@band='1']"].add_element tile_vrt.elements["VRTDataset/VRTRasterBand[@band='1']/SimpleSource"]
      vrt.elements["VRTDataset/VRTRasterBand[@band='2']"].add_element tile_vrt.elements["VRTDataset/VRTRasterBand[@band='2']/SimpleSource"]
      vrt.elements["VRTDataset/VRTRasterBand[@band='3']"].add_element tile_vrt.elements["VRTDataset/VRTRasterBand[@band='3']/SimpleSource"]
      vrt.elements["VRTDataset/VRTRasterBand[@band='4']"].add_element tile_vrt.elements["VRTDataset/VRTRasterBand[@band='4']/SimpleSource"]
      vrt
    end.tap do |vrt|
      vrt.elements.each("VRTDataset/VRTRasterBand/@blockYSize", &:remove)
      vrt.elements.each("VRTDataset/Metadata", &:remove)
      vrt.elements["VRTDataset"].add_attributes "rasterXSize" => raster_size[0], "rasterYSize" => raster_size[1]
      File.write vrt_path, vrt
    end

    log_update "nswtopo: finalising #{raster_info}"
    OS.gdal_translate vrt_path, png_path
  end
end

#render_gemf(gemf_path, name:, **options, &block) ⇒ Object



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
# File 'lib/nswtopo/formats/gemf.rb', line 4

def render_gemf(gemf_path, name:, **options, &block)
  Dir.mktmppath do |temp_dir|
    ranges = tiled_web_map(temp_dir, **options, extension: "gemf", &block).sort_by do |tile|
      [tile.col, tile.row]
    end.group_by(&:zoom)

    header, source = "", "nswtopo"
    # 3.1 overall header:
    header << [4, 256].pack("L>L>")
    # 3.2 sources:
    header << [1, 0, source.bytesize, source].pack("L>L>L>a#{source.bytesize}")
    # 3.3 number of ranges:
    header << [ranges.length].pack("L>")

    offset = header.bytesize + ranges.size * 32
    paths = ranges.each do |zoom, tiles|
      cols = tiles.map(&:col)
      rows = tiles.map(&:row)
      # 3.3 range data:
      header << [zoom, *cols.minmax, *rows.minmax, 0, offset].pack("L>L>L>L>L>L>Q>")
      offset += tiles.size * 12
    end.each do |zoom, tiles|
      # 3.4 range details:
      tiles.each do |tile|
        header << [offset, tile.path.size].pack("Q>L>")
        offset += tile.path.size
      end
    end.values.flatten.map(&:path)

    gemf_path.open("wb") do |file|
      file.write header
      # 4 data area:
      paths.each do |path|
        file.write path.binread
      end
    end
  end
end

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



49
50
51
52
53
54
55
56
57
# File 'lib/nswtopo/formats.rb', line 49

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

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



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
148
149
150
151
152
153
154
155
# File 'lib/nswtopo/formats/kmz.rb', line 50

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 = @cutline.reproject_to_wgs84.bounds
  wgs84_dimensions = wgs84_bounds.map do |min, max|
    (max - min) / degree_resolution
  end

  max_zoom = Math::log2(wgs84_dimensions.max).ceil - Math::log2(Kmz::TILE_SIZE).to_i
  png_path = yield(ppi: ppi)

  Dir.mktmppath do |temp_dir|
    log_update "kmz: resizing image pyramid"
    pyramid = (0..max_zoom).map do |zoom|
      resolution = degree_resolution * 2**(max_zoom - zoom)
      tif_path = temp_dir / "#{name}.kmz.zoom.#{zoom}.tif"
      next zoom, resolution, tif_path
    end.inject(ThreadPool.new, &:<<).each do |zoom, resolution, tif_path|
      OS.gdalwarp "-t_srs", "EPSG:4326", "-tr", resolution, resolution, "-r", "bilinear", "-dstalpha", png_path, tif_path
    end.map do |zoom, resolution, tif_path|
      degrees_per_tile = resolution * Kmz::TILE_SIZE
      corners = JSON.parse(OS.gdalinfo "-json", tif_path)["cornerCoordinates"]
      top_left = corners["upperLeft"]

      counts = corners.values.transpose.map(&:minmax).map do |min, max|
        (max - min) / degrees_per_tile
      end.map(&:ceil)

      indices_bounds = [top_left, 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.entries
      end.inject(:product).map(&:transpose).map(&:reverse).to_h

      next zoom, [indices_bounds, tif_path]
    end.to_h

    kmz_dir = temp_dir.join("#{name}.kmz").tap(&:mkpath)
    pyramid.flat_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

        ["-srcwin", indices[0] * Kmz::TILE_SIZE, indices[1] * Kmz::TILE_SIZE, Kmz::TILE_SIZE, Kmz::TILE_SIZE, tif_path, tile_png_path]
      end
    end.tap do |tiles|
      log_update "kmz: creating %i tiles" % tiles.length
    end.inject(ThreadPool.new, &:<<).each do |*args|
      OS.gdal_translate "--config", "GDAL_PAM_ENABLED", "NO", *args
    end.map(&:last).inject(ThreadPool.new, &:<<).in_groups do |*tile_png_paths|
      dither *tile_png_paths
    rescue Dither::Missing
    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|
          extents = @dimensions.map { |dimension| dimension * @scale / 1000.0 }
          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], @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:, **options, &block) ⇒ Object



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

def render_mbtiles(mbtiles_path, name:, **options, &block)
  wgs84_bounds = @cutline.reproject_to_wgs84.bounds
  sql = <<~SQL
    CREATE TABLE metadata (name TEXT, value TEXT);
    INSERT INTO metadata VALUES ("name", "#{name}");
    INSERT INTO metadata VALUES ("type", "baselayer");
    INSERT INTO metadata VALUES ("version", "1.1");
    INSERT INTO metadata VALUES ("description", "#{name}");
    INSERT INTO metadata VALUES ("format", "png");
    INSERT INTO metadata VALUES ("bounds", "#{wgs84_bounds.transpose.flatten.join ?,}");
    CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB);
  SQL

  Dir.mktmppath do |temp_dir|
    tiled_web_map(temp_dir, **options, extension: "mbtiles", &block).each do |tile|
      sql << %Q[INSERT INTO tiles VALUES (#{tile.zoom}, #{tile.col}, #{tile.row}, readfile("#{tile.path}"));\n]
    end

    OS.sqlite3 mbtiles_path do |stdin|
      stdin.puts sql
      stdin.puts ".exit"
    end
  end
end

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



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
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
# File 'lib/nswtopo/formats/pdf.rb', line 4

def render_pdf(pdf_path, ppi: nil, background:, **options)
  if ppi
    OS.gdal_translate "-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, background: background

      REXML::Document.new(svg_path.read).tap do |xml|
        xml.elements["svg"].tap do |svg|
          style = "@media print { @page { margin: 0 0 -1mm 0; size: %s %s; } }"
          svg.add_element("style").text = style % svg.attributes.values_at("width", "height")
        end

        # replace fill pattern paint with manual pattern mosaic to work around Chrome PDF bug
        xml.elements.each("//svg//use[@id][@fill][@href]") do |use|
          id = use.attributes["id"]

          # find the pattern id, content id, pattern element and content element
          next unless /^url\(#(?<pattern_id>.*)\)$/ =~ use.attributes["fill"]
          next unless /^#(?<content_id>.*)$/ =~ use.attributes["href"]
          next unless pattern = use.elements["preceding::defs/pattern[@id='#{pattern_id}'][@width][@height]"]
          next unless content = use.elements["preceding::defs/g[@id='#{content_id}']"]

          # change pattern element to a group
          pattern.attributes.delete "patternUnits"
          pattern.name = "g"

          # create a clip path to apply to the fill pattern mosaic
          content_clip = REXML::Element.new "clipPath"
          content_clip.add_attribute "id", "#{content_id}.clip"

          # create a clip path to apply to pattern element
          pattern_clip = REXML::Element.new "clipPath"
          pattern_clip.add_attribute "id", "#{pattern_id}.clip"
          pattern.add_attribute "clip-path", "url(##{pattern_id}.clip)"

          # move content and clip paths into defs
          pattern.previous_sibling = pattern_clip
          pattern.next_sibling = content
          content.next_sibling = content_clip

          # replace fill paint with a container for the fill pattern mosaic
          fill = REXML::Element.new "g"
          fill.add_attribute "clip-path", "url(##{content_id}.clip)"
          fill.add_attribute "id", "#{id}.fill"
          use.previous_sibling = fill
          use.add_attribute "fill", "none"

          xml.elements.each("//use[@href='##{id}']") do |use|
            use_fill = REXML::Element.new "use"
            use_fill.add_attribute "href", "##{id}.fill"
            use.previous_sibling = use_fill
          end

          # get pattern size
          pattern_size = %w[width height].map do |name|
            pattern.attributes[name].tap { pattern.attributes.delete name }
          end.map(&:to_f)

          # create pattern clip
          pattern_size.each.with_object(0).inject(&:product).values_at(3,2,0,1).tap do |corners|
            pattern_clip.add_element "path", "d" => %w[M L L L].zip(corners).push("Z").join(?\s)
          end

          # add paths to content clip, get content coverage area, and create fill pattern mosaic
          content.elements.collect("path[@d]", &:itself).each.with_index do |path, index|
            path.add_attribute "id", "#{content_id}.#{index}"
            content_clip.add_element "use", "href" => "##{content_id}.#{index}"
          end.flat_map do |path|
            path.attributes["d"].scan /(\d+(?:\.\d+)?) (\d+(?:\.\d+)?)/
          end.transpose.map do |coords|
            coords.map(&:to_f).minmax
          end.zip(pattern_size).map do |(min, max), size|
            (min...max).step(size).entries
          end.inject(&:product).each do |x, y|
            fill.add_element "use", "href" => "##{pattern_id}", "x" => x, "y" => y
          end
        end

        svg_path.write xml
      end

      log_update "chrome: rendering PDF"
      Chrome.with_browser("file://#{svg_path}") do |browser|
        browser.print_to_pdf(pdf_path) do |doc|
          bbox = [0, 0, dimensions[0] * 72 / 25.4, dimensions[1] * 72 / 25.4]
          bounds = cutline.coordinates[0][...-1].map do |coords|
            coords.zip(dimensions).map { |coord, dimension| coord / dimension }
          end.flatten
          lpts = [0, 0].zip(    [1, 1]).inject(&:product).values_at(0,1,3,2).flatten
          gpts = [0, 0].zip(dimensions).inject(&:product).values_at(0,1,3,2).then do |corners|
            # ISO 32000-2 specifies projected coordinates instead of WGS84, but not observed in practice
            GeoJSON.multipoint(corners, projection: projection).reproject_to_wgs84.coordinates.map(&:to_a).map(&:reverse)
          end.flatten
          pcsm = [25.4/72, 0, 0, 0, 25.4/72, 0, 0, 0, 1, 0, 0, 0]

          doc.pages.first[:VP] = [doc.add({
            Type: :Viewport,
            BBox: bbox,
            Measure: doc.add({
              Type: :Measure,
              Subtype: :GEO,
              Bounds: bounds,
              GCS: doc.add({
                Type: :PROJCS,
                WKT: projection.wkt2
              }),
              GPTS: gpts,
              LPTS: lpts,
              PCSM: pcsm
            })
          })]

          doc.trailer.info[:Creator] = "nswtopo"
          doc.version = "1.7"
        end
      end
    end
  end
end

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



27
28
29
30
31
32
33
34
35
36
# File 'lib/nswtopo/formats.rb', line 27

def render_png(png_path, ppi: PPI, dither: false, **options)
  ppm = (ppi / 0.0254).round
  OS.exiftool yield(ppi: ppi, dither: dither), *%W[
    -PNG:PixelsPerUnitX=#{ppm}
    -PNG:PixelsPerUnitY=#{ppm}
    -o #{png_path}
  ]
rescue OS::Missing
  FileUtils.cp yield(ppi: ppi, dither: dither), png_path
end

#render_svg(svg_path, background:, **options) ⇒ Object



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
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
# File 'lib/nswtopo/formats/svg.rb', line 20

def render_svg(svg_path, background:, **options)
  if uptodate?("map.svg", "map.yml")
    log_update "nswtopo: reading existing map SVG"
    xml = REXML::Document.new read("map.svg")
    xml.elements["svg/metadata/rdf:RDF/rdf:Description"].add_attributes("xmp:ModifyDate" => Time.now.iso8601)

  else
    width, height = @dimensions
    xml = REXML::Document.new
    xml << REXML::XMLDecl.new(1.0, "utf-8")
    svg = xml.add_element "svg",
      "width"  => "#{width}mm",
      "height" => "#{height}mm",
      "viewBox" => "0 0 #{width} #{height}",
      "text-rendering" => "geometricPrecision",
      "xmlns" => "http://www.w3.org/2000/svg",
      "xmlns:nswtopo" => "http://nswtopo.com"

     = svg.add_element("metadata")
    .add_element("nswtopo:map",
      "projection" => @neatline.projection.wkt2,
      "neatline" => @neatline.coordinates.to_json,
      "centre" => @centre.to_json,
      "scale" => @scale,
      "rotation" => @rotation
    )
    .add_element("rdf:RDF",
      "xmlns:rdf" => "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
      "xmlns:xmp" => "http://ns.adobe.com/xap/1.0/",
      "xmlns:dc"  => "http://purl.org/dc/elements/1.1/"
    ).add_element("rdf:Description",
      "xmp:CreatorTool" => VERSION.creator_string,
      "dc:format" => "image/svg+xml"
    )

    # add defs for map filters and masks
    defs = svg.add_element("defs", "id" => "map.defs")
    defs.add_element("rect", "id" => "map.rect", "width" => width, "height" => height)
    defs.add_element("path", "id" => "map.neatline", "d" => @neatline.svg_path_data)
    defs.add_element("clipPath", "id" => "map.clip").add_element("use", "href" => "#map.neatline")

    # add a filter converting alpha channel to cutout mask
    defs.add_element("filter", "id" => "map.filter.cutout").tap do |filter|
      filter.add_element("feComponentTransfer", "in" => "SourceAlpha")
    end

    Enumerator.new do |yielder|
      labels = Layer.new "labels", self, Config.fetch("labels", {}).merge("type" => "Labels")
      layers.reject do |layer|
        log_update "reading: #{layer.name}"
        layer.empty?
      end.each do |layer|
        next if Config["labelling"] == false
        labels.add layer if VectorRender === layer
      end.push(labels).each.with_object [[], []] do |layer, (cutouts, knockouts)|
        log_update "compositing: #{layer.name}"
        new_knockouts, knockout = [], "map.mask.knockout.#{knockouts.length+1}"
        layer.render(cutouts: cutouts, knockout: knockout) do |object|
          case object
          when Labels::ConvexHulls then labels << object
          when VectorRender::Cutout then cutouts << object
          when VectorRender::Knockout then new_knockouts << object
          when REXML::Element
            object.attributes["mask"] ||= "url(#map.mask.knockout.#{knockouts.length})" unless "defs" == object.name
            yielder << object
          end
        end
        knockouts << new_knockouts if new_knockouts.any?
      end.last.push([]).each.with_index do |knockouts, index|
        mask = defs.add_element("mask", "id" => "map.mask.knockout.#{index}")
        content = mask.add_element("g", "id" => "map.mask.knockout.#{index}.content")
        content.add_element("use", "href" => "#map.mask.knockout.#{index+1}.content") if knockouts.any?
        content.add_element("use", "href" => "#map.rect", "fill" => "white", "stroke" => "none") if knockouts.none?
        knockouts.group_by(&:buffer).map do |buffer, knockouts|
          group = content.add_element("g", "filter" => "url(#map.filter.knockout.#{buffer})")
          knockouts.each do |knockout|
            group.add_element knockout.use
          end
        end
      end.flatten.group_by(&:buffer).keys.each do |buffer|
        filter = defs.add_element("filter", "id" => "map.filter.knockout.#{buffer}")
        filter.add_element("feColorMatrix", "values" => "0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 5 0")
        filter.add_element("feMorphology", "operator" => "dilate", "radius" => buffer) unless buffer.zero?
        filter.add_element("feComponentTransfer").add_element("feFuncA", "type" => "discrete", "tableValues" => "0 1")
      end
    end.reject do |element|
      svg.add_element(element) if "defs" == element.name
    end.tap do
      svg.add_element("use", "id" => "map.background", "href" => "#map.neatline", "fill" => "white")
    end.chunk do |element|
      element.attributes["mask"]
    end.each.with_object(svg.add_element("g", "clip-path" => "url(#map.clip)")) do |(mask, elements), clip_group|
      elements.each.with_object(clip_group.add_element("g", "mask" => mask)) do |element, mask_group|
        mask_group.add_element element
        element.delete_attribute "mask"
      end
    end

    xml.elements.each("svg//defs[not(*)]", &:remove)
    xml.elements["svg/metadata/rdf:RDF/rdf:Description"].add_attributes %w[xmp:ModifyDate xmp:CreateDate].each.with_object(Time.now.iso8601).to_h
    write "map.svg", xml.to_s
  end

  xml.elements["svg/use[@id='map.background']"].add_attributes("fill" => background) if background

  svg_path.open("w") do |file|
    SVGFormatter.new.write xml, file
  end
end

#render_svgz(svgz_path, background:, **options) ⇒ Object



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

def render_svgz(svgz_path, background:, **options)
  Dir.mktmppath do |temp_dir|
    svg_path = temp_dir / "svgz-map.svg"
    render_svg svg_path, background: background
    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



38
39
40
41
42
43
44
45
46
47
# File 'lib/nswtopo/formats.rb', line 38

def render_tif(tif_path, ppi: PPI, dither: false, **options)
  OS.gdal_translate yield(ppi: ppi, dither: dither), *%W[
    -of GTiff
    -co COMPRESS=DEFLATE
    -co ZLEVEL=9
    -mo TIFFTAG_XRESOLUTION=#{ppi}
    -mo TIFFTAG_YRESOLUTION=#{ppi}
    -mo TIFFTAG_RESOLUTIONUNIT=2
  ], tif_path
end

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



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
# File 'lib/nswtopo/formats/zip.rb', line 4

def render_zip(zip_path, name:, ppi: PPI, **options)
  Dir.mktmppath do |temp_dir|
    zip_dir = temp_dir.join("zip").tap(&:mkpath)
    tiles_dir = zip_dir.join("tiles").tap(&:mkpath)
    png_path = yield(ppi: ppi)

    2.downto(0).map.with_index do |level, index|
      geo_transform = geotransform(ppi: ppi / 2**index)
      outsize = @dimensions.map { |dimension| (dimension / geo_transform[1]).ceil }
      case index
      when 0
        thumb_size = outsize.inject(&:<) ? [0, 64] : [64, 0]
        OS.gdal_translate *%w[--config GDAL_PAM_ENABLED NO -r bilinear -outsize], *thumb_size, png_path, zip_dir / "thumb.png"
      when 1
        zip_dir.join("#{name}.ref").open("w") do |file|
          file.puts @projection.wkt2
          file.puts geo_transform.join(?,)
          file.puts outsize.join(?,)
        end
      end
      img_path = index.zero? ? png_path : temp_dir / "map.#{level}.png"
      next level, outsize, img_path
    end.inject(ThreadPool.new, &:<<).each do |level, outsize, img_path|
      OS.gdal_translate *%w[-r bicubic -outsize], *outsize, png_path, img_path unless img_path.exist?
    end.flat_map do |level, outsize, img_path|
      outsize.map do |px|
        (0...px).step(256).with_index.entries
      end.inject(&:product).map do |(col, j), (row, i)|
        tile_path = tiles_dir / "#{level}x#{i}x#{j}.png"
        size = [-col, -row].zip(outsize).map(&:sum).zip([256, 256]).map(&:min)
        %w[--config GDAL_PAM_ENABLED NO -srcwin] + [col, row, *size, img_path, tile_path]
      end
    end.tap do |tiles|
      log_update "zip: creating %i tiles" % tiles.length
    end.inject(ThreadPool.new, &:<<).each do |*args|
      OS.gdal_translate *args
    end.map(&:last).tap do |tile_paths|
      log_update "zip: optimising %i tiles" % tile_paths.length
    end.inject(ThreadPool.new, &:<<).in_groups do |*tile_paths|
      dither *tile_paths
    rescue Dither::Missing
    end

    zip zip_dir, zip_path
  end
end