module NSWTopo::Formats
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_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_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