module NSWTopo::Formats

Constants

PPI

Public Class Methods

===(ext) click to toggle source
# File lib/nswtopo/formats.rb, line 17
def self.===(ext)
  extensions.any? ext
end
extensions() click to toggle source
# File lib/nswtopo/formats.rb, line 13
def self.extensions
  instance_methods.grep(/^render_([a-z]+)/) { $1 }
end

Public Instance Methods

rasterise(png_path, external:, **options) click to toggle source
# 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) { |ppi: ppi| ... } click to toggle source
# 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) { |ppi: ppi| ... } click to toggle source
# 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) { |resolution: resolution| ... } click to toggle source
# 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 = <<~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|
        png_path = nil
        zoom.downto(0).inject([]) do |levels, zoom|
          resolution = Mbtiles::RESOLUTION / 2**zoom
          indices, dimensions, topleft = web_mercator_bounds.map do |lower, upper|
            ((lower - Mbtiles::ORIGIN) / resolution / Mbtiles::TILE_SIZE).floor ... ((upper - Mbtiles::ORIGIN) / resolution / Mbtiles::TILE_SIZE).ceil
          end.map.with_index do |indices, axis|
            [indices, (indices.last - indices.first) * Mbtiles::TILE_SIZE, Mbtiles::ORIGIN + (axis.zero? ? indices.first : indices.last) * Mbtiles::TILE_SIZE * resolution]
          end.transpose
          tile_path = temp_dir.join("#{name}.mbtiles.#{zoom}.%09d.png").to_s
          levels << [resolution, indices, dimensions, topleft, tile_path, zoom]
          break levels if indices.map(&:size).all? { |size| size < 3 }
          levels
        end.tap do |(resolution, *, zoom), *|
          png_path = yield(resolution: resolution)
        end.tap do |levels|
          log_update "mbtiles: tiling for zoom levels %s" % levels.map(&:last).minmax.uniq.join(?-)
        end.each.concurrently do |resolution, indices, dimensions, topleft, tile_path, zoom|
          tif_path, tfw_path = %w[tif tfw].map { |ext| temp_dir / "#{name}.mbtiles.#{zoom}.#{ext}" }
          WorldFile.write topleft, resolution, 0, tfw_path
          OS.convert "-size", dimensions.join(?x), "canvas:none", "-type", "TrueColorAlpha", "-depth", 8, tif_path
          OS.gdalwarp "-s_srs", @projection, "-t_srs", "EPSG:3857", "-r", "lanczos", "-dstalpha", png_path, tif_path
          OS.convert tif_path, "-quiet", "+repage", "-crop", "#{Mbtiles::TILE_SIZE}x#{Mbtiles::TILE_SIZE}", tile_path
        end.map do |resolution, indices, dimensions, topleft, tile_path, zoom|
          indices[1].to_a.reverse.product(indices[0].to_a).map.with_index do |(row, col), index|
            [tile_path % index, zoom, col, row]
          end
        end.flatten(1).each do |tile_path, zoom, col, row|
          sql << %Q[INSERT INTO tiles VALUES (#{zoom}, #{col}, #{row}, readfile("#{tile_path}"));\n]
        end.tap do |tiles|
          log_update "mbtiles: optimising %i tiles" % tiles.length
        end.map(&:first).each.concurrent_groups do |png_paths|
          dither *png_paths
        end
        OS.sqlite3 mbtiles_path do |stdin|
          stdin.puts sql
          stdin.puts ".exit"
        end
      end
    end
render_pdf(pdf_path, ppi: nil, external: nil, **options) { |ppi: ppi| ... } click to toggle source
# 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) { |ppi: ppi, dither: dither| ... } click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) { |ppi: ppi, dither: dither| ... } click to toggle source
# 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) { |ppi: ppi| ... } click to toggle source
# 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