module NSWTopo::Formats

def self.===(ext)

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

def self.extensions

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

def rasterise(png_path, background:, ppi: nil, resolution: nil)

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

def render_gemf(gemf_path, name:, **options, &block)

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

def render_jpg(jpg_path, ppi: PPI, **options)

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

def render_kmz(kmz_path, name:, ppi: PPI, **options)

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

def render_mbtiles(mbtiles_path, name:, **options, &block)

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

def render_pdf(pdf_path, ppi: nil, background:, **options)

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

def render_png(png_path, ppi: PPI, dither: false, **options)

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

def render_svg(svg_path, background:, **options)

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"
    metadata = svg.add_element("metadata")
    metadata.add_element("nswtopo:map",
      "projection" => @neatline.projection.wkt2,
      "neatline" => @neatline.coordinates.to_json,
      "centre" => @centre.to_json,
      "scale" => @scale,
      "rotation" => @rotation
    )
    metadata.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

def render_svgz(svgz_path, background:, **options)

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

def render_tif(tif_path, ppi: PPI, dither: false, **options)

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

def render_zip(zip_path, name:, ppi: PPI, **options)

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