diff --git a/CHANGELOG.md b/CHANGELOG.md index f161c941..8043ecaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,21 @@ because it turns other people's test suites red. refuses a text value of ten million characters. At precision 3 or less a line has fewer points than that, one for each step of longitude the globe has, and asking for more is refused with the number that fits. +- **GeoJSON: holes, features with no place, both types of id, and bbox.** + `holes` gives every polygon up to 100 000 holes of four points, each running + the other way round to its outline as RFC 7946 asks. Holes need 6 or more + `vertices`, fewer of them fit at precision 3 or less, and asking for more is + refused with the number that fits. `unlocated` takes the place of every + fifth feature (`some`) or of every one (`all`), written as a geometry of + null. `ids` makes every id a number as before, a string such as `f2`, the + two in turn (`mixed`), or leaves it out (`none`) - GDAL keeps only the + numbers of a mixed file and warns that several features share an id. + `bbox` gives the collection and every feature with a place the box their + coordinates lie in. The collection's box comes after its features, because + its extent is known only after the last one, and a box across the + antimeridian has its west edge greater than its east edge. GDAL opens a file + whose bbox is wrong without a word, so a reader that relies on bbox needs a + test of its own. Files made with the earlier settings keep their bytes. - **A length of time as a setting.** `duration`, `change_interval` and `keyframe_interval` take `10s`, `1m30s`, `1h`, `500ms` or `59.9s`, to the millisecond. A bare number is refused rather than read as seconds, and so is a length that does not diff --git a/README.md b/README.md index 6ae3872b..670dd75f 100644 --- a/README.md +++ b/README.md @@ -617,7 +617,7 @@ recipe. `tfg formats ` prints the allowed range or list for each: | `log` | `entry_format`, `timestamps`, `rate`, `methods`, `status_mix`, `level_mix`, `ip_version`, `line_ending` | | `txt`, `md`, `xml` | `encoding`, `bom` | | `json` | `formatting` | -| `geojson` | `geometry`, `formatting`, `precision`, `altitude`, `vertices`, `winding`, `antimeridian` | +| `geojson` | `geometry`, `formatting`, `precision`, `altitude`, `vertices`, `holes`, `winding`, `antimeridian`, `unlocated`, `ids`, `bbox` | | `svg` | `width`, `height` | | `html` | `structure` | | `yaml`, `toml` | none in this build - the document is a fixed shape, and a size is the only thing to ask for | diff --git a/internal/format/geojson/feature.go b/internal/format/geojson/feature.go index 686f21d8..4181606c 100644 --- a/internal/format/geojson/feature.go +++ b/internal/format/geojson/feature.go @@ -2,6 +2,7 @@ package geojson import ( "math" + "slices" // D11 promises the same bytes from the same seed, so a deliberate, // reproducible generator is the product rather than a weakness. Nothing // here ever makes a secret. @@ -32,16 +33,28 @@ type records struct { e emitter d drawing tail []byte + closing []byte shortest int64 + + // seen is the box every feature written so far lies in, for the bbox of + // the collection, and before is what it was until the last feature. The + // one feature built only to learn it does not fit is thrown away, and its + // place must go with it - a box that takes in a feature the file does not + // hold is the same lie as a number the file skips. + seen, before extent } // values is everything about a feature except its geometry and its note, // drawn or chosen in one place so the smallest feature is measured with the -// same code that writes every other one. +// same code that writes every other one. bounds is the box of its geometry, +// set only when the feature carries a bbox. type values struct { name, tagA, tagB, city string whole, cents, zip int64 active bool + id int64 + idForm string + bounds extent } func newRecords(s settings) *records { @@ -54,12 +67,16 @@ func newRecords(s settings) *records { func (r *records) Shortest() int64 { return r.shortest } // Discard hands back the number the thrown away feature took with it, so the -// closing one carries it instead and the ids read 1..N. The kind follows the -// number, so the closing feature is the kind the thrown away one would have -// been. -func (r *records) Discard() { r.next-- } +// closing one carries it instead and the ids read 1..N, and the place it took +// in the collection's box. The kind follows the number, so the closing feature +// is the kind the thrown away one would have been. +func (r *records) Discard() { + r.next-- + r.seen = r.before +} func (r *records) Append(dst []byte, rng *rand.Rand) []byte { + r.before = r.seen dst = r.draw(dst, rng) dst = appendPhrase(dst, rng, 3+rng.IntN(5)) dst = r.shut(dst) @@ -69,31 +86,54 @@ func (r *records) Append(dst []byte, rng *rand.Rand) []byte { // AppendExact writes the closing feature at exactly n bytes, the end of the // collection included. The note takes the remainder up to noteCap, and spaces // after the feature take the rest. +// +// The end of the collection is built after the closing feature is drawn, +// because its bbox takes that feature in as well. func (r *records) AppendExact(dst []byte, rng *rand.Rand, n int64) []byte { mark := len(dst) dst = r.draw(dst, rng) at := len(dst) dst = r.shut(dst) r.tail = append(r.tail[:0], dst[at:]...) - fill := n - int64(len(dst)-mark) - int64(len(r.s.layout.epilogue)) + r.closing = r.epilogue(r.closing[:0], r.seen) + fill := n - int64(len(dst)-mark) - int64(len(r.closing)) note := min(fill, noteCap) dst = core.AppendFiller(dst[:at], words, note, nil) dst = append(dst, r.tail...) dst = appendSpaces(dst, fill-note) - return append(dst, r.s.layout.epilogue...) + return append(dst, r.closing...) +} + +// epilogue is what closes the collection: the end of its features, its bbox +// when one was asked for and any feature has a place, and the end of the +// object. +func (r *records) epilogue(dst []byte, x extent) []byte { + dst = append(dst, r.s.layout.after...) + if r.s.bbox && x.set { + e := &r.e + e.b, e.depth, e.fresh = dst, 1, false + e.key("bbox") + e.box(x, r.g, r.s.dims()) + dst = e.b + } + return append(dst, r.s.layout.end...) } // draw writes the next feature up to the opening quote of its note. func (r *records) draw(dst []byte, rng *rand.Rand) []byte { r.next++ - k := r.s.kinds[(r.next-1)%int64(len(r.s.kinds))] + k := r.s.kindAt(r.next) r.d.draw(rng, r.s, r.g, k) v := values{ name: word(rng), whole: 100000 + rng.Int64N(899999), cents: rng.Int64N(100), active: rng.IntN(2) == 0, tagA: word(rng), tagB: word(rng), city: word(rng), - zip: 10000 + rng.Int64N(90000), + zip: 10000 + rng.Int64N(90000), id: r.next, idForm: r.s.idAt(r.next), } - return r.write(dst, r.next, k, v) + if r.s.bbox { + v.bounds = r.d.extent() + r.seen.grow(v.bounds, r.d.stride) + } + return r.write(dst, k, v) } // measureShortest is the closing feature at its longest with an empty note: @@ -138,27 +178,54 @@ func ClosingFeatureBounds(props map[string]string) (carried, whole int64, err er // worstLength is the length of the longest closing feature of kind k with n // points, the end of the collection included. +// +// Its id is the widest number in the widest form the settings write, and both +// boxes hold the widest number at every axis. The box of the collection is +// counted whenever the settings draw any geometry - with some features +// unlocated the first one always has a place, so the box is always there. func (r *records) worstLength(k kind, n int) int64 { s := r.s s.vertices = n r.d.worst(s, r.g, k) widest := values{name: longestWord, tagA: longestWord, tagB: longestWord, city: longestWord, - whole: 999999, cents: 99, zip: 99999} - buf := r.shut(r.write(nil, math.MaxInt64, k, widest)) - return int64(len(buf) + len(s.layout.epilogue)) + whole: 999999, cents: 99, zip: 99999, id: math.MaxInt64, idForm: s.widestID()} + if s.bbox { + widest.bounds = r.d.extent() + } + buf := r.shut(r.write(nil, k, widest)) + // Into the buffer the closing feature reuses, rather than a new one for + // each kind and each count measured: fourteen of those with mixed were + // enough to take the generator past the allocation ceiling of the guard + // that keeps a file out of memory (CI on #172). + r.closing = r.epilogue(r.closing[:0], widestBox(r.s, r.g)) + return int64(len(buf) + len(r.closing)) +} + +// widestBox is a box with the widest number the grid writes at every axis, or +// none when the settings draw no geometry at all. +func widestBox(s settings, g grid) extent { + if slices.Equal(s.kinds, []kind{kindNone}) { + return extent{} + } + var x extent + x.add([]int64{-g.lon, -g.lat, g.low}) + return x } // write is one feature through the opening quote of its note, from a drawing // already made and values already chosen. -func (r *records) write(dst []byte, id int64, k kind, v values) []byte { +func (r *records) write(dst []byte, k kind, v values) []byte { e := &r.e e.b = append(dst, r.s.layout.start...) e.depth = featureDepth e.open('{') e.key("type") e.text("Feature") - e.key("id") - e.whole(id) + e.id(v.id, v.idForm) + if v.bounds.set { + e.key("bbox") + e.box(v.bounds, r.g, r.s.dims()) + } e.key("geometry") r.geometry(k) e.key("properties") @@ -202,6 +269,22 @@ func (r *records) properties(v values) { e.close('}') } +// id writes the id member in the form asked for: a number, the same number as +// text after an f, or nothing at all. Every form counts 1..N, so a test can +// still find a feature that went missing. +func (e *emitter) id(n int64, form string) { + switch form { + case Number: + e.key("id") + e.whole(n) + case String: + e.key("id") + e.b = append(e.b, '"', 'f') + e.whole(n) + e.b = append(e.b, '"') + } +} + // shut closes the note, the properties and the feature. func (r *records) shut(dst []byte) []byte { r.e.b = append(dst, '"') diff --git a/internal/format/geojson/geojson.go b/internal/format/geojson/geojson.go index d0f0b5ac..feda9d40 100644 --- a/internal/format/geojson/geojson.go +++ b/internal/format/geojson/geojson.go @@ -84,8 +84,8 @@ func (generator) Plan(r format.Request) (format.Plan, error) { if err != nil { return format.Plan{}, err } - if most := mostPoints(s.kinds, gridFor(s.precision)); int64(s.vertices) > most { - return format.Plan{}, refuseCrowded(s, most) + if err := refusal(s); err != nil { + return format.Plan{}, err } if min := minimumBytes(s); r.Bytes < min { return format.Plan{}, &format.BelowMinimumError{ @@ -108,8 +108,12 @@ func (generator) Plan(r format.Request) (format.Plan, error) { Precision: s.precision, Altitude: s.altitude, Vertices: s.vertices, + Holes: s.holes, Winding: s.winding(), Antimeridian: s.antimeridian, + Unlocated: s.unlocated, + IDs: s.ids, + BBox: s.bbox, // Stated even though it is always false here, so a test can assert // on it without knowing which formats carry a label internally. format.PropertyLabelEmbedded: false, @@ -118,6 +122,23 @@ func (generator) Plan(r format.Request) (format.Plan, error) { }, nil } +// refusal is why the globe has no room for what these settings draw, or nil. +// Holes ask for nothing when no outline is drawn, and nothing asks for room +// when every feature is unlocated. +func refusal(s settings) error { + g := gridFor(s.precision) + if most := mostPoints(s.kinds, g); int64(s.vertices) > most { + return refuseCrowded(s, most) + } + if s.holes == 0 || !draws(s.kinds, pieceRing) { + return nil + } + if most := mostHoles(g); s.vertices < minVerticesWithHoles || int64(s.holes) > most { + return refuseHoles(s, most) + } + return nil +} + func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { m, ok := p.Memo.(memo) if !ok { diff --git a/internal/format/geojson/geometry.go b/internal/format/geojson/geometry.go index 9f852cd4..1ea29e35 100644 --- a/internal/format/geojson/geometry.go +++ b/internal/format/geojson/geometry.go @@ -1,8 +1,13 @@ package geojson -// geometry writes the geometry object of kind k from the drawing already made. +// geometry writes the geometry object of kind k from the drawing already made, +// or null for a feature with no place. func (r *records) geometry(k kind) { e := &r.e + if k == kindNone { + e.b = append(e.b, "null"...) + return + } e.open('{') e.key("type") e.text(typeName[k]) @@ -54,7 +59,7 @@ func (r *records) coordinates(k kind) { } // piece writes piece j of the drawing: a position for a point, an array of -// positions for a line, and an array holding one ring for an outline. +// positions for a line, and for an outline an array of its rings. func (r *records) piece(p piece, j int) { from := 0 if j > 0 { @@ -67,10 +72,50 @@ func (r *records) piece(p piece, j int) { case pieceLine: r.run(from, to, false) case pieceRing: - r.e.open('[') - r.e.next() - r.run(from, to, r.s.reversed) - r.e.close(']') + r.rings(from, to) + } +} + +// rings is an outline and its holes, the outline first as RFC 7946 asks. The +// holes are the last positions of the piece, five each, and each runs the +// other way round to the outline - clockwise under rfc7946 and +// counter-clockwise under reversed. +func (r *records) rings(from, to int) { + e := &r.e + outline := to - r.s.holes*holePositions + e.open('[') + e.next() + r.run(from, outline, r.s.reversed) + for at := outline; at < to; at += holePositions { + e.next() + r.run(at, at+holePositions, !r.s.reversed) + } + e.close(']') +} + +// box writes the bbox of an extent: every axis of the south-west corner, then +// every axis of the north-east one. A box across the antimeridian is written +// with its west edge greater than its east edge, and one that reaches all the +// way round is the whole circle, -180 to 180 (RFC 7946 sections 5.2 and 5.3). +// dims is how many axes a position has. +func (e *emitter) box(x extent, g grid, dims int) { + west, east := g.wrap(x.lo[0]), g.wrap(x.hi[0]) + if x.hi[0]-x.lo[0] >= 2*g.lon { + west, east = -g.lon, g.lon + } + e.open('[') + e.corner(g, west, x.lo[1:dims]) + e.corner(g, east, x.hi[1:dims]) + e.close(']') +} + +// corner is the axes of one corner of a box: its longitude, then the rest. +func (e *emitter) corner(g grid, lon int64, rest []int64) { + e.next() + e.fixed(lon, g.places, g.scale) + for _, v := range rest { + e.next() + e.fixed(v, g.places, g.scale) } } @@ -96,10 +141,7 @@ func (r *records) run(from, to int, backwards bool) { func (r *records) position(i int) { e := &r.e p := r.d.pos[i*r.d.stride : (i+1)*r.d.stride] - x := p[0] - if x > r.g.lon { - x -= 2 * r.g.lon - } + x := r.g.wrap(p[0]) e.open('[') for j, v := range p { if j == 0 { diff --git a/internal/format/geojson/layout.go b/internal/format/geojson/layout.go index f6f70b6e..57b91f04 100644 --- a/internal/format/geojson/layout.go +++ b/internal/format/geojson/layout.go @@ -3,7 +3,9 @@ package geojson import "strconv" // layout is what stands around the features: what opens the collection, what -// goes before and after each feature, and what closes it. +// goes before and after each feature, and what closes it - after closes the +// features array and end the collection, with the collection's bbox between +// the two when there is one. // // The features themselves go through emitter, which knows only whether to // indent. Unlike the json format, whose records are flat enough to be held as @@ -16,7 +18,8 @@ type layout struct { prologue string start string between string - epilogue string + after string + end string } // featureDepth is how deep a feature sits: inside the collection and inside @@ -30,7 +33,8 @@ var layouts = map[string]layout{ name: RecordPerLine, prologue: `{"type":"FeatureCollection","features":[` + "\n", between: ",\n", - epilogue: "\n]}\n", + after: "\n]", + end: "}\n", }, // No whitespace anywhere and no newline at the end, the same promise the // json format makes for the same word. @@ -38,7 +42,8 @@ var layouts = map[string]layout{ name: Minified, prologue: `{"type":"FeatureCollection","features":[`, between: ",", - epilogue: "]}", + after: "]", + end: "}", }, // What JSON.stringify(x, null, 2) and json.dumps(x, indent=2) write: two // spaces a level and every value on its own line, every coordinate number @@ -51,7 +56,8 @@ var layouts = map[string]layout{ prologue: "{\n \"type\": \"FeatureCollection\",\n \"features\": [\n", start: " ", between: ",\n", - epilogue: "\n ]\n}\n", + after: "\n ]", + end: "\n}\n", }, } diff --git a/internal/format/geojson/settings.go b/internal/format/geojson/settings.go index 774a42a4..b8771b98 100644 --- a/internal/format/geojson/settings.go +++ b/internal/format/geojson/settings.go @@ -14,8 +14,27 @@ const ( Precision = "precision" Altitude = "altitude" Vertices = "vertices" + Holes = "holes" Winding = "winding" Antimeridian = "antimeridian" + Unlocated = "unlocated" + IDs = "ids" + BBox = "bbox" +) + +// The values of unlocated: how many features have no geometry. None is shared +// with ids, which has no features without an id the same way. +const ( + None = "none" + Some = "some" + All = "all" +) + +// The values of ids - the two types RFC 7946 allows, both of them in turn, and +// no member at all. Mixed is the word geometry takes for the same idea. +const ( + Number = "number" + String = "string" ) // The values of geometry, spelled as a recipe spells them: the RFC 7946 type @@ -57,6 +76,20 @@ const ( // A million points in one ring is the size that breaks things: measured // 2026-10-07, one such ring is 16 MB of text, read by GDAL in one feature. maxVertices = 1_000_000 + + // An outline keeps its middle free for holes only with two points above + // it and two below - see pierced. + minVerticesWithHoles = 6 + // A hundred thousand holes is half a million positions in one polygon, + // a few megabytes - the kind of land cover outline that slows a reader + // down, at a size a test still writes in seconds. + maxHoles = 100_000 + + // unlocatedEvery is how often a feature has no geometry with unlocated + // set to some. Five shares no factor with the seven kinds mixed takes in + // turn, so each kind loses a feature now and then rather than one kind + // every time, and the first feature always has a place. + unlocatedEvery = 5 ) // settings is every choice a file is made with, read once in Plan. @@ -67,8 +100,12 @@ type settings struct { precision int altitude bool vertices int + holes int reversed bool antimeridian bool + unlocated string + ids string + bbox bool } // parse reads the settings a request carries. @@ -90,21 +127,65 @@ func parse(props map[string]string) (settings, error) { } values[p.Name] = v } - // Both have passed their declared range in valueOf, so neither can fail. + // All three have passed their declared range in valueOf, so none can fail. precision, _ := strconv.Atoi(values[Precision]) vertices, _ := strconv.Atoi(values[Vertices]) + holes, _ := strconv.Atoi(values[Holes]) return settings{ geometry: values[Geometry], - kinds: kindsOf(values[Geometry]), + kinds: kindsOf(values[Geometry], values[Unlocated]), layout: layouts[values[Formatting]], precision: precision, altitude: values[Altitude] == "true", vertices: vertices, + holes: holes, reversed: values[Winding] == Reversed, antimeridian: values[Antimeridian] == "true", + unlocated: values[Unlocated], + ids: values[IDs], + bbox: values[BBox] == "true", }, nil } +// kindAt is the kind feature number n carries. The kind follows the number, +// so a feature thrown away and drawn again is the kind it would have been, +// and so is a feature with no geometry - the kinds after it keep their turns. +func (s settings) kindAt(n int64) kind { + if s.unlocated == Some && n%unlocatedEvery == 0 { + return kindNone + } + return s.kinds[(n-1)%int64(len(s.kinds))] +} + +// idAt is how feature number n writes its id. With mixed the odd numbers are +// numbers and the even ones text. +func (s settings) idAt(n int64) string { + if s.ids == Mixed { + if n%2 == 0 { + return String + } + return Number + } + return s.ids +} + +// dims is how many axes every position has. +func (s settings) dims() int { + if s.altitude { + return 3 + } + return 2 +} + +// widestID is the id form that takes the most bytes for the same number, for +// the smallest closing feature, which has to hold for every feature. +func (s settings) widestID() string { + if s.ids == Mixed { + return String + } + return s.ids +} + // winding is the value of winding the file was made with. func (s settings) winding() string { if s.reversed { @@ -127,8 +208,14 @@ func valueOf(props map[string]string, p format.Property) (string, error) { } // properties is the declaration, in the order parse reads it and a person -// meets it. +// meets it: what is drawn first, then the pitfalls and the members around it. func properties() []format.Property { + return append(drawingProperties(), pitfallProperties()...) +} + +// drawingProperties are the settings of what a feature is drawn as and how +// the document is laid out. +func drawingProperties() []format.Property { return []format.Property{ { Name: Geometry, Kind: format.PropertyChoice, @@ -161,17 +248,48 @@ func properties() []format.Property { Default: strconv.Itoa(defaultVertices), Detail: "How many points every line, outline and MultiPoint has. A feature larger than 1 MiB is followed by spaces that fill the last bytes, and at precision 3 or less fewer points fit, because each needs its own step of longitude.", }, + { + Name: Holes, Kind: format.PropertyInt, + Min: 0, Max: maxHoles, Unit: "holes", + Default: "0", + Detail: "How many holes every polygon has inside its outline. Each hole has four points and runs the other way round to its outline, as RFC 7946 asks. Holes need 6 or more vertices, fewer of them fit at precision 3 or less, and points and lines do not change.", + }, + } +} + +// pitfallProperties are the settings readers disagree about: which way an +// outline runs, a shape across the antimeridian, a feature with no place, the +// type of an id and the bbox. +func pitfallProperties() []format.Property { + return []format.Property{ { Name: Winding, Kind: format.PropertyChoice, Choices: []string{Reversed, RFC7946}, Default: RFC7946, - Detail: "Which way polygon outlines run - rfc7946 is counter-clockwise and reversed is clockwise. RFC 7946 tells readers to accept both, and they disagree about which side of the outline is inside. Points and lines do not change.", + Detail: "Which way polygon outlines run - rfc7946 is counter-clockwise and reversed is clockwise, and holes always run the other way. RFC 7946 tells readers to accept both, and they disagree about which side of the outline is inside. Points and lines do not change.", }, { Name: Antimeridian, Kind: format.PropertyBool, Default: "false", Detail: "Whether every line and outline crosses the 180th meridian without being cut in two. RFC 7946 asks for such shapes to be cut, and readers differ - read as flat coordinates, a crossing outline crosses itself and runs the other way round. A single point cannot cross and is placed beside it.", }, + { + Name: Unlocated, Kind: format.PropertyChoice, + Choices: []string{All, None, Some}, + Default: None, + Detail: "How many features have no place - RFC 7946 writes their geometry as null. some takes the place of every fifth feature, from the fifth on, and all takes it from every one, so the settings that shape a geometry change nothing.", + }, + { + Name: IDs, Kind: format.PropertyChoice, + Choices: []string{Mixed, None, Number, String}, + Default: Number, + Detail: "Which type every feature id has. RFC 7946 allows a number or a string, mixed gives odd features a number and even ones a string such as f2, and none leaves the id out. Readers that turn the id into a key may keep only one of the two types.", + }, + { + Name: BBox, Kind: format.PropertyBool, + Default: "false", + Detail: "Whether the collection and every feature with a place carry a bbox, the box their coordinates lie in. The collection's bbox follows its features, because its extent is known only after the last one, and a box across the antimeridian has a west edge greater than its east edge, as RFC 7946 asks.", + }, } } @@ -199,3 +317,26 @@ func refuseCrowded(s settings, most int64) error { "Ask for %d points or fewer, or for a higher precision.", core.A("Most", most)), } } + +// refuseHoles is the refusal for holes an outline cannot hold: too few points +// to keep its middle free, or more holes than the globe has room for at the +// precision asked for. Keyed by holes, the number the person added last and +// the one the reason talks about. The advice is in the reason as well as on +// its own, for the reason refuseCrowded gives. +func refuseHoles(s settings, most int64) error { + err := &format.PropertyValueError{Format: "geojson", Key: Holes, Value: strconv.Itoa(s.holes)} + if s.vertices < minVerticesWithHoles { + err.Reason = core.Says("geojson.HolesNeedSixPoints", + "an outline keeps its middle free for holes only with two points above it and two below - ask for %d vertices or more, or for no holes", + core.A("Least", minVerticesWithHoles)) + err.Remedy = core.Says("geojson.AskForMorePointsOrNoHoles", + "Ask for %d vertices or more, or for no holes.", core.A("Least", minVerticesWithHoles)) + return err + } + err.Reason = core.Says("geojson.EachHoleNeedsSteps", + "each hole needs four steps of longitude and an outline with holes spans at most half the globe, so at precision %d it has room for %d - ask for that many or fewer, or for a higher precision", + core.A("Precision", s.precision), core.A("Most", most)) + err.Remedy = core.Says("geojson.AskForFewerHoles", + "Ask for %d holes or fewer, or for a higher precision.", core.A("Most", most)) + return err +} diff --git a/internal/format/geojson/shape.go b/internal/format/geojson/shape.go index b864bc90..561ab99c 100644 --- a/internal/format/geojson/shape.go +++ b/internal/format/geojson/shape.go @@ -9,7 +9,7 @@ import ( "slices" ) -// kind is one of the seven geometry types of RFC 7946. +// kind is one of the seven geometry types of RFC 7946, or no geometry at all. type kind int const ( @@ -20,6 +20,9 @@ const ( kindMultiLine kindMultiPolygon kindCollection + // kindNone is a feature with no place, written as geometry null. It is + // drawn from no pieces, so it draws nothing. + kindNone ) // typeName is how each kind is written in the file. @@ -31,6 +34,7 @@ var typeName = [...]string{ kindMultiLine: "MultiLineString", kindMultiPolygon: "MultiPolygon", kindCollection: "GeometryCollection", + kindNone: "", } var kindOf = map[string]kind{ @@ -46,11 +50,17 @@ var kindOf = map[string]kind{ // everyKind is mixed: all seven, in the order RFC 7946 introduces them. var everyKind = []kind{kindPoint, kindLine, kindPolygon, kindMultiPoint, kindMultiLine, kindMultiPolygon, kindCollection} -func kindsOf(geometry string) []kind { - if geometry == Mixed { +// kindsOf is the kinds a file draws. With every feature unlocated it draws +// none, whatever geometry says, so nothing asks for room on the globe. +func kindsOf(geometry, unlocated string) []kind { + switch { + case unlocated == All: + return []kind{kindNone} + case geometry == Mixed: return everyKind + default: + return []kind{kindOf[geometry]} } - return []kind{kindOf[geometry]} } // piece is what a geometry is drawn from. A MultiPoint is drawn as a line's @@ -74,6 +84,7 @@ var pieces = [...][]piece{ kindMultiLine: {pieceLine, pieceLine}, kindMultiPolygon: {pieceRing, pieceRing}, kindCollection: {piecePoint, pieceLine, pieceRing}, + kindNone: nil, } // grid is the precision as whole numbers: a coordinate is a count of steps of @@ -110,28 +121,56 @@ func gridFor(places int) grid { // globe by a step on each side, so a shape never meets itself round the back. func (g grid) room() int64 { return 2*g.lon - 2 } +// wrap is a longitude as written: one drawn past 180 degrees comes back on the +// other side of the antimeridian. +func (g grid) wrap(x int64) int64 { + if x > g.lon { + return x - 2*g.lon + } + return x +} + // needs is the fewest steps of longitude a piece of n points spans. A line // moves right at every point. An outline goes right along the bottom and back -// left along the top, so it needs room for the longer of its two halves. -func needs(p piece, n int64) int64 { +// left along the top, so it needs room for the longer of its two halves - and +// with holes, room for them as well: a cell of four steps each between the two +// columns a step in from the ends (see pierced). +func needs(p piece, n, holes int64) int64 { switch p { case pieceLine: return n - 1 case pieceRing: - return (n - 2) - (n-2)/2 + 1 + half := (n - 2) - (n-2)/2 + 1 + if holes > 0 { + return max(half, holeCell*holes+2) + } + return half default: return 0 } } -func widest(ps []piece, n int64) int64 { +func widest(ps []piece, n, holes int64) int64 { var most int64 for _, p := range ps { - most = max(most, needs(p, n)) + most = max(most, needs(p, n, holes)) } return most } +// holeCell is the fewest steps of longitude one hole takes: two for the hole +// and one either side of it. +const holeCell = 4 + +// mostHoles is the most holes an outline can hold at this precision - needs +// turned round for holes, in half the globe rather than all of it. An outline +// widened for holes may have only six points, so an edge of it can run from +// one end nearly to the other, and an edge as long as half the globe or more +// reads as the short way round the other side to a reader that draws edges on +// the sphere - an outline crossing the antimeridian nobody asked for (measured +// 2026-10-07 with 118 holes at precision 0, before this limit). +func mostHoles(g grid) int64 { return (g.lon - 2) / holeCell } + // mostPoints is the largest vertices the globe has room for at this precision, // given the kinds a file draws - needs turned round, for the piece that needs // the most. Points alone use no vertices at all, so they set no limit. @@ -184,16 +223,27 @@ type box struct { // their ends. The parts of a multi geometry sit in separate bands of latitude, // so they never overlap. Nothing is checked after the fact, because nothing // can go wrong that a check would have to catch. +// +// A feature with no place draws nothing and takes nothing from rng. func (d *drawing) draw(rng *rand.Rand, s settings, g grid, k kind) { d.reset(s) + if k == kindNone { + return + } d.rng, d.g, d.altitude = rng, g, s.altitude ps := pieces[k] n := int64(s.vertices) - b := box{w: max(widest(ps, n), g.scale/5+rng.Int64N(g.scale*4/5+1))} + holes := holesIn(ps, s) + b := box{w: max(widest(ps, n, holes), g.scale/5+rng.Int64N(g.scale*4/5+1))} if s.antimeridian { b.w = max(b.w, 2) } b.h = max(1, g.scale/20+rng.Int64N(g.scale/5+1)) + if holes > 0 { + // Two steps either side of the middle at the least: the outline keeps + // to the outer one and the holes reach no further than the inner one. + b.h = max(b.h, 2) + } pitch := 2*b.h + 3 if s.antimeridian { // Starting at least a step short of 180 and ending at least a step past @@ -211,12 +261,32 @@ func (d *drawing) draw(rng *rand.Rand, s settings, g grid, k kind) { case pieceLine: d.line(b, c, n) case pieceRing: - d.ring(b, c, n) + d.polygon(b, c, n, holes) } d.ends = append(d.ends, len(d.pos)/d.stride) } } +// holesIn is the holes the kind drawn from ps has: the setting for a kind with +// an outline, and none for points and lines, which holes leave as they were, +// byte for byte. +func holesIn(ps []piece, s settings) int64 { + if slices.Contains(ps, pieceRing) { + return int64(s.holes) + } + return 0 +} + +// polygon is an outline and its holes. Without holes it is the outline every +// earlier file drew, with the same draws in the same order. +func (d *drawing) polygon(b box, c, n, holes int64) { + if holes == 0 { + d.ring(b, c, n) + return + } + d.pierced(b, c, n, holes) +} + func (d *drawing) reset(s settings) { d.pos = d.pos[:0] d.ends = d.ends[:0] @@ -284,6 +354,64 @@ func (d *drawing) half(b box, count, edge, sign int64) { } } +// holePoints is how many points every hole has, and holePositions what one +// writes: those and the first again. +const ( + holePoints = 4 + holePositions = holePoints + 1 +) + +// pierced is an outline of n points with holes inside it, every one of them +// strictly inside and none touching another - simple by construction, like +// the outline alone. +// +// The outline alone lets its halves come back to the middle row between +// points, and with two points a half the free part of the middle can be a +// single step wide. So here each half keeps gap rows or more from the middle, +// and its first and last points stand one step in from the ends. Between +// those two columns the band of 2*gap-1 rows round the middle is inside the +// outline whatever was drawn. The holes are drawn in that band, one to a cell, +// each by ring at four points. +// +// What keeps a hole off the outline and off its neighbours is a margin, and the +// margin is a share of the space rather than a step. At fifteen decimal places +// a step is 10^-15 degrees, below what a double tells apart at a longitude +// of a hundred - read by GEOS, holes a step apart touched and cut the polygon +// in two (measured 2026-10-07). So a hole keeps a quarter of its cell clear on +// either side and reaches only half way to the outline above and below it. +// needs makes the box wide enough, and refuseHoles keeps n at six or more, +// two points a half. +func (d *drawing) pierced(b box, c, n, holes int64) { + first := len(d.pos) + bottom := (n - 2) / 2 + gap := max(2, b.h/2) + d.add(b.x0, c) + d.pinned(b, bottom, c-gap, -1, b.h-gap) + d.add(b.x0+b.w, c) + from := len(d.pos) + d.pinned(b, n-2-bottom, c+gap, 1, b.h-gap) + d.reverse(from) + d.pos = append(d.pos, d.pos[first:first+d.stride]...) + + // A cell no wider than a quarter of the globe, so no edge of a hole is + // half the globe long either, when the outline is wide for its points. + cell := min((b.w-2)/holes, d.g.lon/2) + margin := max(1, cell/holeCell) + for j := int64(0); j < holes; j++ { + at := b.x0 + 1 + j*cell + margin + d.ring(box{x0: at, w: cell - 2*margin, h: max(1, gap/2)}, c, holePoints) + } +} + +// pinned is one half of a pierced outline: count points, the first one step in +// from the left end and the last one step in from the right, the rest between +// them in slots as half places them, all from edge outwards by up to spread. +func (d *drawing) pinned(b box, count, edge, sign, spread int64) { + d.add(b.x0+1, edge+sign*d.rng.Int64N(spread+1)) + d.half(box{x0: b.x0 + 1, w: b.w - 2, h: spread + 1}, count-2, edge, sign) + d.add(b.x0+b.w-1, edge+sign*d.rng.Int64N(spread+1)) +} + // reverse turns round the positions from index from to the end. func (d *drawing) reverse(from int) { st := d.stride @@ -300,8 +428,9 @@ func (d *drawing) reverse(from int) { func (d *drawing) worst(s settings, g grid, k kind) { d.reset(s) widest := []int64{-g.lon, -g.lat, g.low}[:d.stride] + holes := int(holesIn(pieces[k], s)) for _, p := range pieces[k] { - for i := 0; i < positions(p, s.vertices); i++ { + for i := 0; i < positions(p, s.vertices, holes); i++ { d.pos = append(d.pos, widest...) } d.ends = append(d.ends, len(d.pos)/d.stride) @@ -309,14 +438,53 @@ func (d *drawing) worst(s settings, g grid, k kind) { } // positions is how many positions a piece of n points writes. An outline -// repeats its first point at the end. -func positions(p piece, n int) int { +// repeats its first point at the end, and so does every hole after it. +func positions(p piece, n, holes int) int { switch p { case pieceLine: return n case pieceRing: - return n + 1 + return n + 1 + holes*holePositions default: return 1 } } + +// extent is the box a set of positions lies in, in the coordinates they were +// drawn in - a longitude past 180 degrees not yet wrapped round - so a box +// across the antimeridian is one interval here and two only when written. +type extent struct { + lo, hi [3]int64 + set bool +} + +// extent is the box this drawing lies in. A drawing of no positions, a +// feature with no place, has none. +func (d *drawing) extent() extent { + var x extent + for i := 0; i+d.stride <= len(d.pos); i += d.stride { + x.add(d.pos[i : i+d.stride]) + } + return x +} + +func (x *extent) add(p []int64) { + if !x.set { + copy(x.lo[:], p) + copy(x.hi[:], p) + x.set = true + return + } + for j, v := range p { + x.lo[j] = min(x.lo[j], v) + x.hi[j] = max(x.hi[j], v) + } +} + +// grow widens x to take in y as well. +func (x *extent) grow(y extent, stride int) { + if y.set { + x.add(y.lo[:stride]) + x.add(y.hi[:stride]) + } +} diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go index 973fbd17..869d63a4 100644 --- a/internal/guard/generatorbytes_test.go +++ b/internal/guard/generatorbytes_test.go @@ -366,6 +366,21 @@ func goldenCases() map[string]engine.Target { Properties: map[string]string{"antimeridian": "true", "winding": "reversed", "altitude": "true", "precision": "15"}}, "geojson_capped_note": {ID: "g", Format: "geojson", Sizes: engine.Uniform(1, 3900000), Label: true, Properties: map[string]string{"geometry": "polygon", "vertices": "100000"}}, + // The second set of settings, one case for each new path: outlines + // with holes, a box on every feature and on the collection across the + // antimeridian with a height and indented, features with no place and + // ids of both types, and a collection box that goes all the way round - + // each read by the structural checker when its value was recorded, the + // last two reporting a crossing box and a box round the globe. + "geojson_8kib_holes": {ID: "g", Format: "geojson", Sizes: engine.Uniform(1, 8192), Label: true, + Properties: map[string]string{"holes": "2"}}, + "geojson_8kib_boxes_across": {ID: "g", Format: "geojson", Sizes: engine.Uniform(1, 8192), Label: true, + Properties: map[string]string{"bbox": "true", "antimeridian": "true", "altitude": "true", "formatting": "indented"}}, + "geojson_8kib_unlocated_mixed_ids": {ID: "g", Format: "geojson", Sizes: engine.Uniform(1, 8192), Label: true, + Properties: map[string]string{"unlocated": "some", "ids": "mixed"}}, + "geojson_box_round_the_globe": {ID: "g", Format: "geojson", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"bbox": "true", "antimeridian": "true", "precision": "0", "vertices": "300", + "geometry": "linestring", "formatting": "minified", "ids": "string"}}, // The label is a byte affecting switch, not a cosmetic one, so it is // pinned in both positions. diff --git a/internal/guard/geojson_test.go b/internal/guard/geojson_test.go index d57efc6f..fa7c732a 100644 --- a/internal/guard/geojson_test.go +++ b/internal/guard/geojson_test.go @@ -10,6 +10,7 @@ import ( "regexp" "sort" "strconv" + "strings" "testing" "github.com/donislawdev/TestingFilesGenerator/internal/format" @@ -50,6 +51,32 @@ var geojsonCases = []map[string]string{ {geojson.Vertices: "3", geojson.Antimeridian: "true"}, {geojson.Precision: "0", geojson.Vertices: "359", geojson.Geometry: geojson.LineString}, {geojson.Precision: "0", geojson.Antimeridian: "true"}, + + // The second set of settings (docs/GEOJSON-2-2026-10-07.md): holes, features + // with no place, the type of the id and the bbox - each alone, then where + // they were measured to be tightest. + {geojson.Holes: "1"}, + {geojson.Holes: "3", geojson.Geometry: geojson.Polygon, geojson.Vertices: "6"}, + {geojson.Holes: "2", geojson.Winding: geojson.Reversed, geojson.Antimeridian: "true", geojson.Altitude: "true"}, + // The most holes a whole-degree grid has room for, in both parts of a + // multi polygon. 118 of them were first allowed, and an outline of six + // points across 357 degrees read as crossing the antimeridian. + {geojson.Holes: "44", geojson.Precision: "0", geojson.Geometry: geojson.MultiPolygon}, + // Fifteen places with holes: a step is less than a double tells apart, and + // holes first drawn a step apart touched and cut the polygon in two for + // GEOS (measured 2026-10-07). + {geojson.Holes: "4", geojson.Precision: "15", geojson.Altitude: "true"}, + {geojson.Unlocated: geojson.Some}, + {geojson.Unlocated: geojson.All, geojson.BBox: "true"}, + {geojson.IDs: geojson.String}, + {geojson.IDs: geojson.Mixed}, + {geojson.IDs: geojson.None}, + {geojson.BBox: "true"}, + {geojson.BBox: "true", geojson.Antimeridian: "true"}, + {geojson.BBox: "true", geojson.Formatting: geojson.Indented, geojson.Altitude: "true"}, + {geojson.BBox: "true", geojson.Formatting: geojson.Minified, geojson.Unlocated: geojson.Some, geojson.IDs: geojson.Mixed}, + {geojson.Holes: "2", geojson.Unlocated: geojson.Some, geojson.IDs: geojson.Mixed, geojson.BBox: "true", + geojson.Antimeridian: "true", geojson.Formatting: geojson.Indented, geojson.Winding: geojson.Reversed}, } // Three readers, three questions. GDAL is the reader a GIS tester has, and it @@ -74,17 +101,7 @@ func TestEveryGeoJSONSettingSurvivesItsReferenceTool(t *testing.T) { for _, size := range []int64{smallest, smallest + 1, 20000} { path := filepath.Join(dir, strconv.Itoa(i)+"_"+strconv.FormatInt(size, 10)+".geojson") features := writeGeoJSON(t, path, size, uint64(31+i), props) - - res := gdal.Check(path) - if !res.Available { - t.Skipf("%s is not installed, so no file was read by GDAL - a skip, not a pass", res.Tool) - } - if res.Err != nil { - t.Fatalf("%v at %d B: %v", props, size, res.Err) - } - if got := oracle.GDALFeatures(res.Output); got != features { - t.Fatalf("%v at %d B: GDAL read %d features and the file holds %d", props, size, got, features) - } + gdalReadsEveryFeature(t, gdal, path, props, features) if v := oracle.ShapelyValid(path, props[geojson.Antimeridian] == "true"); !v.Available { t.Skipf("%s is not installed, so no geometry was judged - a skip, not a pass", v.Tool) } else if v.Err != nil { @@ -112,9 +129,15 @@ func TestTheSmallestGeoJSONFeatureCarriedForwardIsTheOneMeasuredWhole(t *testing geojson.MultiPoint, geojson.MultiLineString, geojson.MultiPolygon, geojson.GeometryCollection} { for _, layout := range []string{geojson.RecordPerLine, geojson.Minified, geojson.Indented} { for _, extra := range []string{"3", "4", "5", "6", "9", "100", "5000"} { + // The settings of the second set take turns at their own pace, so + // every one of their values meets every geometry and layout. props := map[string]string{geojson.Geometry: geometry, geojson.Formatting: layout, geojson.Vertices: extra, geojson.Altitude: strconv.FormatBool(checked%2 == 0), - geojson.Precision: strconv.Itoa(checked % 16)} + geojson.Precision: strconv.Itoa(checked % 16), + geojson.Holes: []string{"0", "1", "7"}[checked%3], + geojson.BBox: strconv.FormatBool(checked/2%2 == 0), + geojson.IDs: []string{geojson.Number, geojson.String, geojson.Mixed, geojson.None}[checked%4], + geojson.Unlocated: []string{geojson.None, geojson.Some, geojson.None, geojson.All, geojson.None}[checked%5]} carried, whole, err := geojson.ClosingFeatureBounds(props) if err != nil { t.Fatalf("%v: %v", props, err) @@ -194,6 +217,121 @@ func TestGeoJSONRefusesMorePointsThanTheGlobeHasRoomFor(t *testing.T) { } } +// A hole takes four steps of longitude and an outline with holes spans at most +// half the globe, so a whole-degree grid has room for 44 and three places for +// 44 999 - the setting's description says fewer fit at precision 3 or less, +// and four places hold the hundred thousand it allows. Holes keep the middle +// of an outline free only with two points above it and two below, so six +// vertices. A geometry with no outline, or no geometry at all, asks for no room +// and is never refused for holes. +func TestGeoJSONRefusesHolesTheOutlineHasNoRoomFor(t *testing.T) { + d, err := format.Get("geojson") + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + props map[string]string + refused bool + }{ + {map[string]string{geojson.Geometry: geojson.Polygon, geojson.Precision: "0", geojson.Holes: "44"}, false}, + {map[string]string{geojson.Geometry: geojson.Polygon, geojson.Precision: "0", geojson.Holes: "45"}, true}, + {map[string]string{geojson.Geometry: geojson.GeometryCollection, geojson.Precision: "3", geojson.Holes: "44999"}, false}, + {map[string]string{geojson.Geometry: geojson.GeometryCollection, geojson.Precision: "3", geojson.Holes: "45000"}, true}, + {map[string]string{geojson.Precision: "4", geojson.Holes: "100000"}, false}, + {map[string]string{geojson.Vertices: "6", geojson.Holes: "1"}, false}, + {map[string]string{geojson.Vertices: "5", geojson.Holes: "1"}, true}, + {map[string]string{geojson.Geometry: geojson.LineString, geojson.Vertices: "3", geojson.Precision: "0", geojson.Holes: "100000"}, false}, + {map[string]string{geojson.Unlocated: geojson.All, geojson.Vertices: "1000000", geojson.Precision: "0", geojson.Holes: "100000"}, false}, + } { + _, err := d.Generator.Plan(format.Request{Bytes: 1 << 40, Properties: c.props}) + var refused *format.PropertyValueError + switch { + case !c.refused && err != nil: + t.Errorf("%v was refused, and it fits: %v", c.props, err) + case c.refused && !(errors.As(err, &refused) && refused.Key == geojson.Holes): + t.Errorf("%v was not refused for its holes: %v", c.props, err) + } + } +} + +// A bbox across the antimeridian has its west edge greater than its east edge, +// and one whose shapes reach all the way round is -180 to 180 (RFC 7946 +// sections 5.2 and 5.3). The reference tools read every case of the second set +// above with a bbox, and this asks that the two states they exist for were +// reached - the checker says which shape the collection's box has, because a +// guard that never makes a crossing box is green for no reason. +func TestAGeoJSONBoxAcrossTheAntimeridianOrRoundTheGlobeSurvivesItsReferenceTool(t *testing.T) { + d, err := format.Get("geojson") + if err != nil { + t.Fatal(err) + } + gdal := mustOracle(t, d) + for _, c := range []struct { + props map[string]string + shape string + }{ + {map[string]string{geojson.BBox: "true", geojson.Antimeridian: "true"}, "crossing"}, + // Whole degrees and lines of 300 points: each line spans 299 degrees + // across 180, so a few of them together reach round the globe. + {map[string]string{geojson.BBox: "true", geojson.Antimeridian: "true", geojson.Precision: "0", + geojson.Vertices: "300", geojson.Geometry: geojson.LineString, geojson.Formatting: geojson.Indented}, "round"}, + } { + path := filepath.Join(t.TempDir(), c.shape+".geojson") + features := writeGeoJSON(t, path, 40000, 7, c.props) + s := oracle.Strict("geojson", path, geojsonSettings(c.props)...) + if !s.Available { + t.Skip("the structural check needs python") + } + if s.Err != nil { + t.Fatalf("%v: %v", c.props, s.Err) + } + if !strings.HasSuffix(strings.TrimSpace(s.Output), "collection box "+c.shape) { + t.Fatalf("%v: the checker reports %q, and this case exists to make a box %s", c.props, s.Output, c.shape) + } + gdalReadsEveryFeature(t, gdal, path, c.props, features) + if v := oracle.ShapelyValid(path, true); !v.Available { + t.Skipf("%s is not installed, so no geometry was judged - a skip, not a pass", v.Tool) + } else if v.Err != nil { + t.Fatalf("%v: %v", c.props, v.Err) + } + } +} + +// gdalMixedIDs is the one complaint GDAL is allowed. With ids=mixed it gives +// every feature whose id is text the same number, then says so - measured +// 2026-10-07 with GDAL 3.13.3, and that loss is the pitfall the setting hands +// a tester. Allowed, not required: another version may stay quiet. +var gdalMixedIDs = regexp.MustCompile(`^Warning 1: Several features with id = -?\d+ have been found\. Altering it to be unique\.`) + +// gdalReadsEveryFeature asks GDAL to read every feature with nothing on +// standard error but the complaint the settings call for, and to read as many +// as the file holds. +func gdalReadsEveryFeature(t *testing.T, gdal oracle.Checker, path string, props map[string]string, features int) { + t.Helper() + res := gdal.Check(path) + if !res.Available { + t.Skipf("%s is not installed, so no file was read by GDAL - a skip, not a pass", res.Tool) + } + if res.Err != nil && !(props[geojson.IDs] == geojson.Mixed && onlyMixedIDComplaint(res.Err.Error())) { + t.Fatalf("%v: %v", props, res.Err) + } + if got := oracle.GDALFeatures(res.Output); got != features { + t.Fatalf("%v: GDAL read %d features and the file holds %d", props, got, features) + } +} + +// onlyMixedIDComplaint is whether every line GDAL complained with is the one +// about ids, and nothing else came with it. +func onlyMixedIDComplaint(complaint string) bool { + lines := strings.Split(strings.TrimSpace(strings.TrimPrefix(complaint, "GDAL complained: ")), "\n") + for _, line := range lines { + if !gdalMixedIDs.MatchString(strings.TrimSpace(line)) { + return false + } + } + return true +} + func mustOracle(t *testing.T, d format.Descriptor) oracle.Checker { t.Helper() c, ok := oracle.For(d.Oracle) diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index 6036d49f..4aa73520 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -167,9 +167,13 @@ var reachableFromTheWindow = []string{ "property:bmp.width", "property:geojson.altitude", "property:geojson.antimeridian", + "property:geojson.bbox", "property:geojson.formatting", "property:geojson.geometry", + "property:geojson.holes", + "property:geojson.ids", "property:geojson.precision", + "property:geojson.unlocated", "property:geojson.vertices", "property:geojson.winding", "property:gif.frames", diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json index 76302645..e9f4b85b 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -411,6 +411,26 @@ "bytes": 3900000, "sha256": "b921833657d0f75935cbd0c571199cb03cd9be8d57ed77f841c4282f87928162", "measured_on": "2026-10-07" + }, + "geojson_8kib_holes": { + "bytes": 8192, + "sha256": "3e128555df65b3247acd30958d7d8367a54d7a81c5adae57037c1f7d2b2670fc", + "measured_on": "2026-10-07" + }, + "geojson_8kib_boxes_across": { + "bytes": 8192, + "sha256": "f0d102a153610f4da0f91d2c35391e16f3d4c2a1e82b97a21274750e93f271ed", + "measured_on": "2026-10-07" + }, + "geojson_8kib_unlocated_mixed_ids": { + "bytes": 8192, + "sha256": "7efffc9703fd90dcfd618267d063a63e5872e103ec686d43eb8b42e120907c32", + "measured_on": "2026-10-07" + }, + "geojson_box_round_the_globe": { + "bytes": 65536, + "sha256": "5c01a4d2461685ed35c3e431da8452742d241e54f7b9e9d9158e87694e70a1dc", + "measured_on": "2026-10-07" } }, "remeasured": [ diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index 890fc39c..89689916 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -734,6 +734,26 @@ "hash": "sha256-2b3b18120924", "other": "polygon" }, + "Choice.ids.mixed": { + "description": "One entry of the ids list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-3f8fee624f43", + "other": "mixed" + }, + "Choice.ids.none": { + "description": "One entry of the ids list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-140bedbf9c3f", + "other": "none" + }, + "Choice.ids.number": { + "description": "One entry of the ids list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-12886f9d0005", + "other": "number" + }, + "Choice.ids.string": { + "description": "One entry of the ids list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-473287f8298d", + "other": "string" + }, "Choice.ip_version.mixed": { "description": "One entry of the ip_version list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-3f8fee624f43", @@ -924,6 +944,21 @@ "hash": "sha256-992a93455c71", "other": "fixed" }, + "Choice.unlocated.all": { + "description": "One entry of the unlocated list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-5ef5ef0364b6", + "other": "all" + }, + "Choice.unlocated.none": { + "description": "One entry of the unlocated list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-140bedbf9c3f", + "other": "none" + }, + "Choice.unlocated.some": { + "description": "One entry of the unlocated list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-a6b46dd0d1ae", + "other": "some" + }, "Choice.winding.reversed": { "description": "One entry of the winding list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-bc64f64fb479", @@ -1004,6 +1039,11 @@ "hash": "sha256-e7755a355839", "other": "Whether every line and outline crosses the 180th meridian without being cut in two. RFC 7946 asks for such shapes to be cut, and readers differ - read as flat coordinates, a crossing outline crosses itself and runs the other way round. A single point cannot cross and is placed beside it." }, + "Detail.format/geojson.bbox": { + "description": "The sentence under the bbox setting of the geojson format, after what it takes.", + "hash": "sha256-2046a38a38a9", + "other": "Whether the collection and every feature with a place carry a bbox, the box their coordinates lie in. The collection's bbox follows its features, because its extent is known only after the last one, and a box across the antimeridian has a west edge greater than its east edge, as RFC 7946 asks." + }, "Detail.format/geojson.formatting": { "description": "The sentence under the formatting setting of the geojson format, after what it takes.", "hash": "sha256-ed33a04494ed", @@ -1014,11 +1054,26 @@ "hash": "sha256-8d7e42488248", "other": "Which kind of geometry each feature carries. With mixed the seven kinds take turns, so a file of seven features or more holds every one of them." }, + "Detail.format/geojson.holes": { + "description": "The sentence under the holes setting of the geojson format, after what it takes.", + "hash": "sha256-8d6a4341bc7f", + "other": "How many holes every polygon has inside its outline. Each hole has four points and runs the other way round to its outline, as RFC 7946 asks. Holes need 6 or more vertices, fewer of them fit at precision 3 or less, and points and lines do not change." + }, + "Detail.format/geojson.ids": { + "description": "The sentence under the ids setting of the geojson format, after what it takes.", + "hash": "sha256-cc9e598ac76e", + "other": "Which type every feature id has. RFC 7946 allows a number or a string, mixed gives odd features a number and even ones a string such as f2, and none leaves the id out. Readers that turn the id into a key may keep only one of the two types." + }, "Detail.format/geojson.precision": { "description": "The sentence under the precision setting of the geojson format, after what it takes.", "hash": "sha256-c34064661db8", "other": "How many decimal places every coordinate is written with. Six is about ten centimetres on the ground, the figure RFC 7946 gives." }, + "Detail.format/geojson.unlocated": { + "description": "The sentence under the unlocated setting of the geojson format, after what it takes.", + "hash": "sha256-d17e6159b9fe", + "other": "How many features have no place - RFC 7946 writes their geometry as null. some takes the place of every fifth feature, from the fifth on, and all takes it from every one, so the settings that shape a geometry change nothing." + }, "Detail.format/geojson.vertices": { "description": "The sentence under the vertices setting of the geojson format, after what it takes.", "hash": "sha256-12c16dec92e1", @@ -1026,8 +1081,8 @@ }, "Detail.format/geojson.winding": { "description": "The sentence under the winding setting of the geojson format, after what it takes.", - "hash": "sha256-c36536a3a1bc", - "other": "Which way polygon outlines run - rfc7946 is counter-clockwise and reversed is clockwise. RFC 7946 tells readers to accept both, and they disagree about which side of the outline is inside. Points and lines do not change." + "hash": "sha256-1de3ea94a487", + "other": "Which way polygon outlines run - rfc7946 is counter-clockwise and reversed is clockwise, and holes always run the other way. RFC 7946 tells readers to accept both, and they disagree about which side of the outline is inside. Points and lines do not change." }, "Detail.format/gif.frames": { "description": "The sentence under the frames setting of the gif format, after what it takes.", @@ -1779,6 +1834,11 @@ "hash": "sha256-d95082a2ee57", "other": "Author" }, + "Label.bbox": { + "description": "The name beside the box of a setting a recipe writes as bbox.", + "hash": "sha256-708f44b1bd98", + "other": "Bbox" + }, "Label.bit_depth": { "description": "The name beside the box of a setting a recipe writes as bit_depth.", "hash": "sha256-cc62f2e89a82", @@ -1964,6 +2024,16 @@ "hash": "sha256-bfe2eca40179", "other": "Height" }, + "Label.holes": { + "description": "The name beside the box of a setting a recipe writes as holes.", + "hash": "sha256-158a3b71a54c", + "other": "Holes" + }, + "Label.ids": { + "description": "The name beside the box of a setting a recipe writes as ids.", + "hash": "sha256-d9aa9d4aca97", + "other": "Ids" + }, "Label.ip_version": { "description": "The name beside the box of a setting a recipe writes as ip_version.", "hash": "sha256-29016036fada", @@ -2114,6 +2184,11 @@ "hash": "sha256-7e8cd2056da7", "other": "Title" }, + "Label.unlocated": { + "description": "The name beside the box of a setting a recipe writes as unlocated.", + "hash": "sha256-9d9b671e4837", + "other": "Unlocated" + }, "Label.vertices": { "description": "The name beside the box of a setting a recipe writes as vertices.", "hash": "sha256-f5af129ea336", @@ -2414,6 +2489,11 @@ "hash": "sha256-e333c47e4b5c", "other": "hertz" }, + "Unit.holes": { + "description": "What a number counts, after it, as in: from 1 to 20000 holes. Written the way the language puts a noun after a number.", + "hash": "sha256-ffce3618dce7", + "other": "holes" + }, "Unit.megapixels": { "description": "What a number counts, after it, as in: from 1 to 20000 megapixels. Written the way the language puts a noun after a number.", "hash": "sha256-2bb37d7da74d", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index 2da4f022..b11523a7 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -146,6 +146,10 @@ "Choice.geometry.multipolygon": { "hash": "sha256-d9cfa92a41b7", "other": "multipoligon" }, "Choice.geometry.point": { "hash": "sha256-251fecd592e5", "other": "punkt" }, "Choice.geometry.polygon": { "hash": "sha256-2b3b18120924", "other": "poligon" }, + "Choice.ids.mixed": { "hash": "sha256-3f8fee624f43", "other": "mieszane" }, + "Choice.ids.none": { "hash": "sha256-140bedbf9c3f", "other": "brak" }, + "Choice.ids.number": { "hash": "sha256-12886f9d0005", "other": "liczba" }, + "Choice.ids.string": { "hash": "sha256-473287f8298d", "other": "tekst" }, "Choice.ip_version.mixed": { "hash": "sha256-3f8fee624f43", "other": "mieszane" }, "Choice.ip_version.v4": { "hash": "sha256-8e38a1ea5c68", "other": "v4" }, "Choice.ip_version.v6": { "hash": "sha256-3e8286044388", "other": "v6" }, @@ -184,6 +188,9 @@ "Choice.structure.fragment": { "hash": "sha256-7587669ca42e", "other": "fragment" }, "Choice.timestamps.advancing": { "hash": "sha256-3656fa4d5539", "other": "rosnące" }, "Choice.timestamps.fixed": { "hash": "sha256-992a93455c71", "other": "stałe" }, + "Choice.unlocated.all": { "hash": "sha256-5ef5ef0364b6", "other": "wszystkie" }, + "Choice.unlocated.none": { "hash": "sha256-140bedbf9c3f", "other": "żaden" }, + "Choice.unlocated.some": { "hash": "sha256-a6b46dd0d1ae", "other": "niektóre" }, "Choice.winding.reversed": { "hash": "sha256-bc64f64fb479", "other": "odwrócony" }, "Choice.winding.rfc7946": { "hash": "sha256-38755035559d", "other": "rfc7946" }, "Detail.damage/zero-head.bytes": { "hash": "sha256-8700126f6712", "other": "Ile bajtów na początku zostaje wyzerowanych. Poniżej czterech niektóre formaty wychodzą z uszkodzeniem, na które żaden czytnik nie narzeka." }, @@ -200,11 +207,15 @@ "Detail.format/docx.paragraphs": { "hash": "sha256-5d0141e9b1da", "other": "Ile akapitów tekstu zawiera dokument. Resztę zamówionego rozmiaru niesie niewidoczna część paczki." }, "Detail.format/geojson.altitude": { "hash": "sha256-702be3958a4b", "other": "Czy każda pozycja niesie trzecią liczbę, wysokość w metrach od -400 do 8848. Program, który czyta dwie liczby na pozycję, musi ją pominąć albo odrzucić plik." }, "Detail.format/geojson.antimeridian": { "hash": "sha256-e7755a355839", "other": "Czy każda linia i każdy obrys przecina 180. południk bez rozcięcia na dwie części. RFC 7946 każe takie kształty rozcinać, a czytniki się różnią - czytany jako płaskie współrzędne, przecinający obrys przecina sam siebie i biegnie w drugą stronę. Pojedynczy punkt nie może przeciąć i leży obok." }, + "Detail.format/geojson.bbox": { "hash": "sha256-2046a38a38a9", "other": "Czy kolekcja i każdy obiekt z położeniem niosą bbox, prostokąt, w którym leżą ich współrzędne. Bbox kolekcji stoi za jej obiektami, bo jej zasięg jest znany dopiero po ostatnim, a prostokąt przecinający antypołudnik ma zachodnią krawędź większą niż wschodnią, jak każe RFC 7946." }, "Detail.format/geojson.formatting": { "hash": "sha256-ed33a04494ed", "other": "Jak ułożony jest dokument. Każdy czytnik przyjmuje wszystkie trzy - zminifikowany stawia cały plik w jednej linii i kończy go bez znaku nowej linii, a z wcięciami stawia każdą liczbę w osobnej linii, co robi z tych samych obiektów plik kilka razy większy." }, "Detail.format/geojson.geometry": { "hash": "sha256-8d7e42488248", "other": "Jaki rodzaj geometrii niesie każdy obiekt. Przy mieszana siedem rodzajów idzie na zmianę, więc plik z co najmniej siedmioma obiektami ma każdy z nich." }, + "Detail.format/geojson.holes": { "hash": "sha256-8d6a4341bc7f", "other": "Ile dziur ma każdy wielokąt wewnątrz obrysu. Każda dziura ma cztery punkty i biegnie w przeciwną stronę niż jej obrys, jak każe RFC 7946. Dziury wymagają co najmniej 6 punktów, przy precyzji 3 lub mniejszej mieści się ich mniej, a punkty i linie się nie zmieniają." }, + "Detail.format/geojson.ids": { "hash": "sha256-cc9e598ac76e", "other": "Jakiego typu jest identyfikator każdego obiektu. RFC 7946 pozwala na liczbę albo tekst, mieszane daje obiektom nieparzystym liczbę, a parzystym tekst taki jak f2, a brak pomija identyfikator. Czytniki, które robią z identyfikatora klucz, mogą zachować tylko jeden z dwóch typów." }, "Detail.format/geojson.precision": { "hash": "sha256-c34064661db8", "other": "Ile miejsc po przecinku ma każda współrzędna. Sześć to około dziesięciu centymetrów w terenie, wartość podana w RFC 7946." }, + "Detail.format/geojson.unlocated": { "hash": "sha256-d17e6159b9fe", "other": "Ile obiektów nie ma położenia - RFC 7946 zapisuje ich geometrię jako null. niektóre zabiera położenie co piątemu obiektowi, od piątego, a wszystkie zabiera je każdemu, więc ustawienia kształtujące geometrię niczego nie zmieniają." }, "Detail.format/geojson.vertices": { "hash": "sha256-12c16dec92e1", "other": "Ile punktów ma każda linia, każdy obrys i każdy multipunkt. Obiekt większy niż 1 MiB jest dopełniany spacjami za nim, a przy precyzji 3 lub mniejszej mieści się mniej punktów, bo każdy potrzebuje własnego kroku długości geograficznej." }, - "Detail.format/geojson.winding": { "hash": "sha256-c36536a3a1bc", "other": "W którą stronę biegną obrysy wielokątów - rfc7946 przeciwnie do ruchu wskazówek zegara, odwrócony zgodnie z nim. RFC 7946 każe czytnikom przyjmować oba, a czytniki nie zgadzają się, która strona obrysu jest w środku. Punkty i linie się nie zmieniają." }, + "Detail.format/geojson.winding": { "hash": "sha256-1de3ea94a487", "other": "W którą stronę biegną obrysy wielokątów - rfc7946 przeciwnie do ruchu wskazówek zegara, odwrócony zgodnie z nim, a dziury zawsze w drugą stronę. RFC 7946 każe czytnikom przyjmować oba, a czytniki nie zgadzają się, która strona obrysu jest w środku. Punkty i linie się nie zmieniają." }, "Detail.format/gif.frames": { "hash": "sha256-d2291fedd06c", "other": "Ile klatek ma animacja. Ustaw 1, żeby dostać nieruchomy obraz." }, "Detail.format/gif.height": { "hash": "sha256-4b336c279584", "other": "Jak wysoki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, "Detail.format/gif.width": { "hash": "sha256-3085ac44f0e0", "other": "Jak szeroki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, @@ -355,6 +366,7 @@ "Label.altitude": { "hash": "sha256-cbc2e75740ca", "other": "Wysokość" }, "Label.antimeridian": { "hash": "sha256-cd0da1397b61", "other": "Antypołudnik" }, "Label.author": { "hash": "sha256-d95082a2ee57", "other": "Autor" }, + "Label.bbox": { "hash": "sha256-708f44b1bd98", "other": "Ramka (bbox)" }, "Label.bit_depth": { "hash": "sha256-cc62f2e89a82", "other": "Głębia bitowa" }, "Label.bom": { "hash": "sha256-e39338101951", "other": "BOM" }, "Label.bulk": { "hash": "sha256-3140100df131", "other": "Przesyłanie masowe" }, @@ -392,6 +404,8 @@ "Label.geometry": { "hash": "sha256-fa9da5754ccf", "other": "Geometria" }, "Label.header": { "hash": "sha256-ba5caa4285a8", "other": "Nagłówek" }, "Label.height": { "hash": "sha256-bfe2eca40179", "other": "Wysokość" }, + "Label.holes": { "hash": "sha256-158a3b71a54c", "other": "Dziury" }, + "Label.ids": { "hash": "sha256-d9aa9d4aca97", "other": "Identyfikatory" }, "Label.ip_version": { "hash": "sha256-29016036fada", "other": "Wersja IP" }, "Label.keyframe_interval": { "hash": "sha256-e2ebcac3d589", "other": "Odstęp klatek kluczowych" }, "Label.keywords": { "hash": "sha256-8bfdb93cafaa", "other": "Słowa kluczowe" }, @@ -422,6 +436,7 @@ "Label.subject": { "hash": "sha256-68971283841a", "other": "Temat" }, "Label.timestamps": { "hash": "sha256-755f099dbf49", "other": "Znaczniki czasu" }, "Label.title": { "hash": "sha256-7e8cd2056da7", "other": "Tytuł" }, + "Label.unlocated": { "hash": "sha256-9d9b671e4837", "other": "Bez położenia" }, "Label.vertices": { "hash": "sha256-f5af129ea336", "other": "Punkty" }, "Label.width": { "hash": "sha256-3019947b6daf", "other": "Szerokość" }, "Label.winding": { "hash": "sha256-a886eddec337", "other": "Kierunek obiegu" }, @@ -482,6 +497,7 @@ "Unit.entries per second": { "hash": "sha256-90ad5144c654", "other": "wpisów na sekundę" }, "Unit.files": { "hash": "sha256-3d7db37d08f9", "other": "plików" }, "Unit.hertz": { "hash": "sha256-e333c47e4b5c", "other": "herców" }, + "Unit.holes": { "hash": "sha256-ffce3618dce7", "other": "dziur" }, "Unit.megapixels": { "hash": "sha256-2bb37d7da74d", "other": "megapikseli" }, "Unit.million cells": { "hash": "sha256-95a765354101", "other": "milionów komórek" }, "Unit.paragraphs": { "hash": "sha256-ec575de14f57", "other": "akapitów" }, diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index ffbcd7ca..8ca764d4 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -1685,16 +1685,36 @@ "hash": "sha256-f2b3d3aeda55", "other": "a collection holds whole features, and the largest one these settings can draw needs that much" }, + "geojson.AskForFewerHoles": { + "description": "Said by internal/format/geojson - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Most}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-cac73bf86f4e", + "other": "Ask for {{.Most}} holes or fewer, or for a higher precision." + }, "geojson.AskForFewerPoints": { "description": "Said by internal/format/geojson - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Most}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-d36b2eba39c1", "other": "Ask for {{.Most}} points or fewer, or for a higher precision." }, + "geojson.AskForMorePointsOrNoHoles": { + "description": "Said by internal/format/geojson - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Least}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-8d41e52b37de", + "other": "Ask for {{.Least}} vertices or more, or for no holes." + }, + "geojson.EachHoleNeedsSteps": { + "description": "Said by internal/format/geojson - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Precision}}, {{.Most}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-5774cee2ea85", + "other": "each hole needs four steps of longitude and an outline with holes spans at most half the globe, so at precision {{.Precision}} it has room for {{.Most}} - ask for that many or fewer, or for a higher precision" + }, "geojson.EachPointNeedsAStep": { "description": "Said by internal/format/geojson - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Precision}}, {{.Most}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-2cbc901989bd", "other": "each point needs its own step of longitude, and at precision {{.Precision}} the globe has room for {{.Most}} - ask for that many or fewer, or for a higher precision" }, + "geojson.HolesNeedSixPoints": { + "description": "Said by internal/format/geojson - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Least}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-6d00afc49eb9", + "other": "an outline keeps its middle free for holes only with two points above it and two below - ask for {{.Least}} vertices or more, or for no holes" + }, "html.HasToBeStructure": { "description": "Said by internal/format/htmlfile - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Document}}, {{.Fragment}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-1a83745fa4b2", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index b53662ed..fcd7eb95 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -335,8 +335,12 @@ "format.ZipTheDirectoryCouldNotBe": { "hash": "sha256-7eb26b5ea426", "other": "zip: nie udało się nazwać katalogu {{.Name}}: {{.Err}}" }, "format.ZipTheFileInsideCannotBe": { "hash": "sha256-8f5ef3b82380", "other": "zip: plik {{.Format}} w środku nie może powstać: {{.Err}}" }, "geojson.ACollectionHoldsWholeFeatures": { "hash": "sha256-f2b3d3aeda55", "other": "kolekcja składa się z całych obiektów, a największy, jaki te ustawienia mogą narysować, potrzebuje tyle" }, + "geojson.AskForFewerHoles": { "hash": "sha256-cac73bf86f4e", "other": "Poproś o {{.Most}} dziur lub mniej albo o wyższą precyzję." }, "geojson.AskForFewerPoints": { "hash": "sha256-d36b2eba39c1", "other": "Poproś o {{.Most}} punktów lub mniej albo o wyższą precyzję." }, + "geojson.AskForMorePointsOrNoHoles": { "hash": "sha256-8d41e52b37de", "other": "Poproś o co najmniej {{.Least}} punktów albo o brak dziur." }, + "geojson.EachHoleNeedsSteps": { "hash": "sha256-5774cee2ea85", "other": "każda dziura potrzebuje czterech kroków długości geograficznej, a obrys z dziurami zajmuje najwyżej pół kuli ziemskiej, więc przy precyzji {{.Precision}} ma miejsce na {{.Most}} - poproś o tyle lub mniej albo o wyższą precyzję" }, "geojson.EachPointNeedsAStep": { "hash": "sha256-2cbc901989bd", "other": "każdy punkt potrzebuje własnego kroku długości geograficznej, a przy precyzji {{.Precision}} kula ziemska ma miejsce na {{.Most}} - poproś o tyle lub mniej albo o wyższą precyzję" }, + "geojson.HolesNeedSixPoints": { "hash": "sha256-6d00afc49eb9", "other": "obrys zostawia środek wolny na dziury tylko z dwoma punktami nad nim i dwoma pod nim - poproś o co najmniej {{.Least}} punktów albo o brak dziur" }, "html.HasToBeStructure": { "hash": "sha256-1a83745fa4b2", "other": "musi to być {{.Document}} albo {{.Fragment}}" }, "html.MinimumFragment": { "hash": "sha256-e716d4f229d7", "other": "fragment zawiera całe bloki, a jeden z nich potrzebuje tyle" }, "html.MinimumPage": { "hash": "sha256-3745440d090a", "other": "strona zawiera nagłówek, treść i całe bloki, a po jednym z nich potrzeba tyle" }, diff --git a/internal/oracle/geoscripts.go b/internal/oracle/geoscripts.go index 8107c195..e324c331 100644 --- a/internal/oracle/geoscripts.go +++ b/internal/oracle/geoscripts.go @@ -67,11 +67,16 @@ func ogrinfo() (string, bool) { // crossing itself opened without a word. It is a question about the shape // rather than about the text, so it lives beside the reader rather than in it. // -// unwrap moves longitudes so no step between neighbours is more than 180 -// degrees before asking. That is how a shape crossing the antimeridian is meant -// to be read. Read flat, as GEOS reads coordinates, the same outline crosses -// itself - measured on the same day, and that is the trap the setting exists -// to hand a tester, not a defect for this question to find. +// unwrap moves every longitude of a shape into the turn of the globe that +// starts at its first line or outline, before asking. That is how a shape +// crossing the antimeridian is meant to be read. Read flat, as GEOS reads +// coordinates, the same outline crosses itself - measured on the same day, and +// that is the trap the setting exists to hand a tester, not a defect for this +// question to find. One turn for the whole shape rather than one per ring, +// because a hole that starts past 180 would otherwise land a turn away from +// its outline, outside it. +// +// A feature with no place has no geometry to judge and is passed over. func ShapelyValid(path string, unwrap bool) Result { return Checker{ Name: "shapely", @@ -94,32 +99,40 @@ except ImportError: path, unwrap = sys.argv[1], sys.argv[2] == "true" -def unwrapped(c): +def first_longitude(g): + if g["type"] == "GeometryCollection": + lines = [m for m in g["geometries"] if m["type"] != "Point"] + return first_longitude(lines[0]) if lines else None + if g["type"] == "Point": + return None + c = g["coordinates"] + while isinstance(c[0], list): + c = c[0] + return c[0] + +def moved(c, first): if isinstance(c[0], (int, float)): - return c - if isinstance(c[0][0], (int, float)): - out = [list(c[0])] - for p in c[1:]: - q = list(p) - while q[0] - out[-1][0] > 180: - q[0] -= 360 - while q[0] - out[-1][0] < -180: - q[0] += 360 - out.append(q) - return out - return [unwrapped(x) for x in c] + return [first + (c[0] - first) % 360] + list(c[1:]) + return [moved(x, first) for x in c] -def geometry(g): +def geometry(g, first): if g["type"] == "GeometryCollection": - return {"type": g["type"], "geometries": [geometry(m) for m in g["geometries"]]} - return {"type": g["type"], "coordinates": unwrapped(g["coordinates"]) if unwrap else g["coordinates"]} + return {"type": g["type"], "geometries": [geometry(m, first) for m in g["geometries"]]} + if first is None: + return g + return {"type": g["type"], "coordinates": moved(g["coordinates"], first)} with open(path, encoding="utf-8") as f: doc = json.load(f) -for feature in doc["features"]: - shaped = shape(geometry(feature["geometry"])) +judged = 0 +for number, feature in enumerate(doc["features"], start=1): + g = feature["geometry"] + if g is None: + continue + shaped = shape(geometry(g, first_longitude(g) if unwrap else None)) if not shaped.is_valid: - print("FAIL feature %s %s: %s" % (feature["id"], feature["geometry"]["type"], explain_validity(shaped))) + print("FAIL feature %d %s: %s" % (number, g["type"], explain_validity(shaped))) sys.exit(1) -print("OK %d features, every geometry valid" % len(doc["features"])) + judged += 1 +print("OK %d features, %d geometries, every one valid" % (len(doc["features"]), judged)) ` diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index dcffb932..d24b4ae0 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -2160,11 +2160,14 @@ class GeoNumber(str): GEOJSON_KINDS = ["Point", "LineString", "Polygon", "MultiPoint", "MultiLineString", "MultiPolygon", "GeometryCollection"] -GEOJSON_FEATURE_KEYS = ["type", "id", "geometry", "properties"] GEOJSON_PROPERTY_KEYS = ["name", "amount", "active", "retired", "tags", "address", "note"] # The longest the closing note gets before spaces take the rest, measured # against GDAL on 2026-10-07 (docs/GEOJSON-2026-10-07.md section 5). GEOJSON_NOTE_CAP = 1 << 20 +# With unlocated=some, every fifth feature has no place (the format's promise). +GEOJSON_UNLOCATED_EVERY = 5 +# Four points and the first again. +GEOJSON_HOLE_POSITIONS = 5 def geo_dump(value, indent, depth=0): @@ -2203,8 +2206,9 @@ def geo_layout(text, doc, layout): elif layout == "minified": expected, last = geo_dump(doc, False), geo_dump(feats[-1], False) elif layout == "record-per-line": + box = ',"bbox":' + geo_dump(doc["bbox"], False) if "bbox" in doc else "" expected = ('{"type":"FeatureCollection","features":[\n' - + ",\n".join(geo_dump(f, False) for f in feats) + "\n]}\n") + + ",\n".join(geo_dump(f, False) for f in feats) + "\n]" + box + "}\n") last = geo_dump(feats[-1], False) else: fail(f"the geojson check was told formatting={layout!r}, which is not a layout this tool writes") @@ -2258,10 +2262,10 @@ def geo_crosses(run): return any(abs(float(a[0]) - float(b[0])) > 180 for a, b in zip(run, run[1:])) -def geo_ring(ring, settings): - """An outline: closed, as many points as ordered, wound the ordered way.""" +def geo_ring(ring, settings, hole=False): + """A ring: closed, wound the ordered way - and a hole the other way round.""" if ring[0] != ring[-1]: - fail("an outline does not end where it starts") + fail(f"{'a hole' if hole else 'an outline'} does not end where it starts") xs = [float(p[0]) for p in ring] if settings["antimeridian"]: for i in range(1, len(xs)): @@ -2271,26 +2275,48 @@ def geo_ring(ring, settings): xs[i] += 360 ys = [float(p[1]) for p in ring] area = sum(xs[i] * ys[i + 1] - xs[i + 1] * ys[i] for i in range(len(ring) - 1)) / 2 - if (area > 0) != (settings["winding"] == "rfc7946"): - fail(f"an outline runs {'counter-clockwise' if area > 0 else 'clockwise'} " + if (area > 0) != ((settings["winding"] == "rfc7946") != hole): + fail(f"{'a hole' if hole else 'an outline'} runs {'counter-clockwise' if area > 0 else 'clockwise'} " f"and winding={settings['winding']} was ordered") +def geo_unlocated(number, settings): + """Whether feature number has no place, by the rule unlocated promises.""" + return settings["unlocated"] == "all" or ( + settings["unlocated"] == "some" and number % GEOJSON_UNLOCATED_EVERY == 0) + + +def geo_id(number, feat, settings): + """The id in the form ids orders: a JSON number, the text f, or none.""" + form = settings["ids"] + if form == "mixed": + form = "string" if number % 2 == 0 else "number" + if form == "none": + return + got = feat["id"] + if form == "number" and not (type(got) is GeoNumber and got == str(number)): + fail(f"feature {number} carries the id {got!r} and ids={settings['ids']} makes it the number {number}") + if form == "string" and not (type(got) is str and got == f"f{number}"): + fail(f"feature {number} carries the id {got!r} and ids={settings['ids']} makes it the text 'f{number}'") + + def geo_feature(number, feat, settings, kinds): + """One feature: its members, its id, its place or the lack of one, its shape. + + Returns whether it has a place, and the box its positions lie in, as drawn, + when a bbox was ordered - worked out only then, because only then is it + asked about.""" if not isinstance(feat, dict): fail(f"feature {number} is {type(feat).__name__} rather than an object") - if list(feat) != GEOJSON_FEATURE_KEYS or feat["type"] != "Feature": - fail(f"feature {number} has the members {list(feat)}, not {GEOJSON_FEATURE_KEYS}") - # Both may be null in RFC 7946, and this tool writes neither null - a - # feature without a place or without properties is a file it did not make. - if not isinstance(feat["geometry"], dict) or not isinstance(feat["properties"], dict): - fail(f"feature {number} has a geometry of {type(feat['geometry']).__name__} and properties of " - f"{type(feat['properties']).__name__} - this tool writes an object for both") - if feat["id"] != str(number): - fail(f"feature {number} carries the id {feat['id']}, so the ids do not run 1..N") - want = kinds[(number - 1) % len(kinds)] - if feat["geometry"]["type"] != want: - fail(f"feature {number} is a {feat['geometry']['type']} and geometry={settings['geometry']} makes it a {want}") + unlocated = geo_unlocated(number, settings) + keys = (["type"] + (["id"] if settings["ids"] != "none" else []) + + (["bbox"] if settings["bbox"] and not unlocated else []) + ["geometry", "properties"]) + if list(feat) != keys or feat["type"] != "Feature": + fail(f"feature {number} has the members {list(feat)}, not {keys}") + geo_id(number, feat, settings) + # properties may be null in RFC 7946 and this tool never writes it so. + if not isinstance(feat["properties"], dict): + fail(f"feature {number} has properties of {type(feat['properties']).__name__} - this tool writes an object") props = feat["properties"] if list(props) != GEOJSON_PROPERTY_KEYS: fail(f"feature {number} has the properties {list(props)}, not {GEOJSON_PROPERTY_KEYS}") @@ -2299,24 +2325,136 @@ def geo_feature(number, feat, settings, kinds): types = [type(props[k]) for k in GEOJSON_PROPERTY_KEYS] if types != [str, GeoNumber, bool, type(None), list, dict, str]: fail(f"feature {number} has properties of the types {[t.__name__ for t in types]}") - dims, n = (3 if settings["altitude"] else 2), settings["vertices"] - for piece, coords in geo_pieces(feat["geometry"]): + if unlocated: + if feat["geometry"] is not None: + fail(f"feature {number} has a geometry and unlocated={settings['unlocated']} leaves it without one") + return False, None + if not isinstance(feat["geometry"], dict): + fail(f"feature {number} has a geometry of {type(feat['geometry']).__name__} - " + f"unlocated={settings['unlocated']} gives it a place") + want = kinds[(number - 1) % len(kinds)] + if feat["geometry"]["type"] != want: + fail(f"feature {number} is a {feat['geometry']['type']} and geometry={settings['geometry']} makes it a {want}") + geo_shape(number, feat["geometry"], settings) + if not settings["bbox"]: + return True, None + lo, hi = geo_extent(feat["geometry"], settings) + geo_bbox(f"feature {number}", feat["bbox"], lo, hi, settings["precision"]) + return True, (lo, hi) + + +def geo_shape(number, geom, settings): + """The pieces of a geometry: their counts, places, edges, crossing and winding.""" + dims, n, holes = (3 if settings["altitude"] else 2), settings["vertices"], settings["holes"] + first = geo_anchor(geom, settings) + for piece, coords in geo_pieces(geom): runs = {"Point": [[coords]], "LineString": [coords], "Polygon": coords}[piece] - # One outline and no holes - with none at all the loop below would have - # nothing to check and a polygon of no rings would pass. - if piece == "Polygon" and len(runs) != 1: - fail(f"feature {number}: a polygon has {len(runs)} rings and this tool writes one") - count = {"Point": 1, "LineString": n, "Polygon": n + 1}[piece] - for run in runs: + # An outline and exactly the holes ordered - with no rings at all the + # loop below would have nothing to check and a polygon of none would pass. + if piece == "Polygon" and len(runs) != 1 + holes: + fail(f"feature {number}: a polygon has {len(runs)} rings and holes={holes} makes it {1 + holes}") + for i, run in enumerate(runs): + hole = piece == "Polygon" and i > 0 + count = GEOJSON_HOLE_POSITIONS if hole else {"Point": 1, "LineString": n, "Polygon": n + 1}[piece] if len(run) != count: - fail(f"feature {number}: a {piece} piece has {len(run)} positions and {count} were ordered") + fail(f"feature {number}: a {'hole' if hole else piece + ' piece'} has {len(run)} positions " + f"and {count} were ordered") for pos in run: geo_position(pos, settings["precision"], dims) - if piece != "Point" and geo_crosses(run) != settings["antimeridian"]: + geo_edges(number, run, first, settings["precision"]) + # A hole crosses the antimeridian when it happens to lie across it, + # so only lines and outlines answer for the setting. + if piece != "Point" and not hole and geo_crosses(run) != settings["antimeridian"]: fail(f"feature {number}: a {piece} {'crosses' if geo_crosses(run) else 'does not cross'} " f"the antimeridian and antimeridian={str(settings['antimeridian']).lower()} was ordered") if piece == "Polygon": - geo_ring(run, settings) + geo_ring(run, settings, hole) + + +def geo_steps(n): + """A number as the file wrote it, in steps of its last decimal place.""" + return int(n.replace(".", "")) + + +def geo_text(v, places): + """A count of steps written back with places decimals, as the format writes numbers.""" + sign, v = ("-" if v < 0 else ""), abs(v) + if places == 0: + return f"{sign}{v}" + scale = 10 ** places + return f"{sign}{v // scale}.{v % scale:0{places}d}" + + +def geo_anchor(geom, settings): + """Where the turn of the globe a shape was drawn in starts, in steps - or None + when nothing was wrapped. + + Across the antimeridian a shape is drawn as one piece, its longitudes running + on past 180, and only written wrapped. Every line and outline starts at its + left end, short of 180 and so written as drawn, and the rest of the shape lies + within a turn of the globe to its east. A shape of points alone lies beside + 180, so its turn starts at 0. Without the setting nothing is wrapped. + """ + if not settings["antimeridian"]: + return None + pieces = geo_pieces(geom) + starts = [coords[0] if piece == "LineString" else coords[0][0] for piece, coords in pieces if piece != "Point"] + return geo_steps(starts[0][0]) if starts else 0 + + +def geo_lon(v, first, places): + """A longitude as written, in steps, put back in the turn the shape was drawn in.""" + x = geo_steps(v) + return x if first is None else first + (x - first) % (360 * 10 ** places) + + +def geo_edges(number, run, first, places): + """Every edge of a run spans less than half the globe, as drawn. + + An edge of half the globe or more reads to a reader that draws edges on the + sphere as the short way round the other side - a crossing of the antimeridian + nobody ordered, or one that was ordered going the wrong way. Walked a pair + at a time, so no copy of the run is kept (review of #172).""" + half, before = 180 * 10 ** places, None + for p in run: + x = geo_lon(p[0], first, places) + if before is not None and abs(x - before) >= half: + fail(f"feature {number}: an edge spans {geo_text(abs(x - before), places)} degrees of longitude, " + f"and every edge this tool draws spans less than half the globe") + before = x + + +def geo_extent(geom, settings): + """The box a geometry's positions lie in, in steps, with the longitudes as + they were drawn - a running minimum and maximum rather than a copy of every + position, and asked for only when a bbox was ordered (review of #172). + + Not a memory bound: the parsed document and the text its layout is rebuilt + into are what the check holds at its peak - 638 MiB for a 40 MiB file of + outlines of 200 000 points and 20 000 holes, measured 2026-10-07 the same + with the copy and without it.""" + first, places = geo_anchor(geom, settings), settings["precision"] + lo = hi = None + for piece, coords in geo_pieces(geom): + for run in {"Point": [[coords]], "LineString": [coords], "Polygon": coords}[piece]: + for pos in run: + p = [geo_lon(pos[0], first, places)] + [geo_steps(v) for v in pos[1:]] + lo = p if lo is None else [min(a, b) for a, b in zip(lo, p)] + hi = p if hi is None else [max(a, b) for a, b in zip(hi, p)] + return lo, hi + + +def geo_bbox(where, got, lo, hi, places): + """A bbox against the box its positions lie in: south-west corner then + north-east, a west edge greater than the east one across the antimeridian, + and -180 to 180 when the shapes reach all the way round (RFC 7946 5.2, 5.3).""" + half = 180 * 10 ** places + west, east = (lo[0] - 2 * half if lo[0] > half else lo[0]), (hi[0] - 2 * half if hi[0] > half else hi[0]) + if hi[0] - lo[0] >= 2 * half: + west, east = -half, half + want = [geo_text(v, places) for v in [west] + lo[1:] + [east] + hi[1:]] + if not isinstance(got, list) or [type(v) for v in got] != [GeoNumber] * len(got) or list(got) != want: + fail(f"{where} has the bbox {got!r} and its positions lie in {want}") def check_geojson(data, settings=None): @@ -2325,14 +2463,18 @@ def check_geojson(data, settings=None): Parsing is CPython's json, and GDAL reads the same file in the reference tool beside this. What only this layer asks: that every number carries the decimal places ordered, that every outline is closed and wound the way - winding says - with longitudes unwrapped where antimeridian says the shape - crosses, because read flat such an outline is wound the other way and - crosses itself (measured 2026-10-07) - that the kinds take turns as - geometry says, that the ids run 1..N, and that the file is byte for byte + winding says and every hole the other way - with longitudes unwrapped where + antimeridian says the shape crosses, because read flat such an outline is + wound the other way and crosses itself (measured 2026-10-07) - that the + kinds take turns as geometry says and unlocated takes the place of the + features it names, that the ids run 1..N in the type ids orders, that every + bbox is the box its positions lie in, and that the file is byte for byte the text its layout makes of its own content. Measured 2026-10-07: GDAL takes a feature with no geometry member and a - latitude of 120 without a word. Both are refused here. + latitude of 120 without a word, and ignores bbox altogether - a box around + another place and one of three numbers both opened without a word. All of + them are refused here. """ import json @@ -2343,8 +2485,12 @@ def check_geojson(data, settings=None): "precision": int(given.get("precision", "6")), "altitude": given.get("altitude", "false") == "true", "vertices": int(given.get("vertices", "8")), + "holes": int(given.get("holes", "0")), "winding": given.get("winding", "rfc7946"), "antimeridian": given.get("antimeridian", "false") == "true", + "unlocated": given.get("unlocated", "none"), + "ids": given.get("ids", "number"), + "bbox": given.get("bbox", "false") == "true", } if data.startswith(b"\xef\xbb\xbf"): fail("the file starts with a byte order mark") @@ -2353,7 +2499,7 @@ def check_geojson(data, settings=None): doc = json.loads(text, parse_int=GeoNumber, parse_float=GeoNumber) except (UnicodeDecodeError, ValueError) as exc: fail(f"not valid UTF-8 JSON: {exc}") - if not isinstance(doc, dict) or list(doc) != ["type", "features"] or doc["type"] != "FeatureCollection": + if not isinstance(doc, dict) or list(doc)[:2] != ["type", "features"] or doc["type"] != "FeatureCollection": fail("the root is not a FeatureCollection with its type and its features") feats = doc["features"] if not isinstance(feats, list) or not feats: @@ -2365,8 +2511,9 @@ def check_geojson(data, settings=None): # to come out as a refusal with a reason rather than a Python traceback, # because a crash also turns a guard red and proves nothing (review of #171). try: - for number, feat in enumerate(feats, start=1): - geo_feature(number, feat, settings, kinds) + found = [geo_feature(number, feat, settings, kinds) for number, feat in enumerate(feats, start=1)] + located = sum(1 for placed, _ in found if placed) + geo_collection_box(doc, [box for _, box in found if box], located, settings) except (TypeError, KeyError, IndexError, AttributeError, ValueError) as exc: fail(f"a feature is not shaped the way this tool writes one: {type(exc).__name__}: {exc}") spaces = geo_layout(text, doc, settings["formatting"]) @@ -2374,7 +2521,33 @@ def check_geojson(data, settings=None): if note > GEOJSON_NOTE_CAP or (spaces and note != GEOJSON_NOTE_CAP): fail(f"the last note is {note} B with {spaces} spaces after the feature - " f"spaces may only follow a note of exactly {GEOJSON_NOTE_CAP} B") - ok(f"{len(feats)} features, {settings['geometry']}, {spaces} trailing spaces") + ok(f"{len(feats)} features, {located} with a place, {settings['geometry']}, {spaces} trailing spaces, " + f"collection box {geo_box_shape(doc)}") + + +def geo_box_shape(doc): + """What the collection's bbox is, said so a guard can ask that a file reached + the state it exists to check: none, plain, crossing the antimeridian, or the + whole way round.""" + if "bbox" not in doc: + return "none" + box = [float(v) for v in doc["bbox"]] + west, east = box[0], box[len(box) // 2] + if (west, east) == (-180, 180): + return "round" + return "crossing" if west > east else "plain" + + +def geo_collection_box(doc, boxes, located, settings): + """The root's members after its features: a bbox taking in every feature + with a place when one was ordered and any feature has one, and nothing else.""" + want = ["type", "features"] + (["bbox"] if settings["bbox"] and located else []) + if list(doc) != want: + fail(f"the collection has the members {list(doc)}, not {want}") + if "bbox" in want: + lo = [min(b[0][i] for b in boxes) for i in range(len(boxes[0][0]))] + hi = [max(b[1][i] for b in boxes) for i in range(len(boxes[0][1]))] + geo_bbox("the collection", doc["bbox"], lo, hi, settings["precision"]) CHECKS = {"png": check_png, "wav": check_wav, "pdf": check_pdf, "zip": check_zip, diff --git a/web/content/ar/site.json b/web/content/ar/site.json index ed905ead..3ee262b5 100644 --- a/web/content/ar/site.json +++ b/web/content/ar/site.json @@ -261,6 +261,7 @@ "megapixels": "ميغابكسل", "decimal places": "منازل عشرية", "points": "نقاط", + "holes": "ثقوب", "million cells": "مليون خلية", "entries per second": "إدخال في الثانية", "files": "ملفات", diff --git a/web/content/cs/site.json b/web/content/cs/site.json index 37d5b56b..091cf67a 100644 --- a/web/content/cs/site.json +++ b/web/content/cs/site.json @@ -260,6 +260,7 @@ "megapixels": "megapixelů", "decimal places": "desetinných míst", "points": "bodů", + "holes": "děr", "million cells": "milionů buněk", "entries per second": "položek za sekundu", "files": "souborů", diff --git a/web/content/de/site.json b/web/content/de/site.json index 3e7e603f..4923a6ce 100644 --- a/web/content/de/site.json +++ b/web/content/de/site.json @@ -260,6 +260,7 @@ "megapixels": "Megapixel", "decimal places": "Nachkommastellen", "points": "Punkte", + "holes": "Löcher", "million cells": "Millionen Zellen", "entries per second": "Einträge pro Sekunde", "files": "Dateien", diff --git a/web/content/en/site.json b/web/content/en/site.json index 9a0acc44..d1c6c9de 100644 --- a/web/content/en/site.json +++ b/web/content/en/site.json @@ -257,6 +257,7 @@ "megapixels": "megapixels", "decimal places": "decimal places", "points": "points", + "holes": "holes", "million cells": "million cells", "entries per second": "entries per second", "files": "files", diff --git a/web/content/es/site.json b/web/content/es/site.json index 633fa5a8..7e001317 100644 --- a/web/content/es/site.json +++ b/web/content/es/site.json @@ -260,6 +260,7 @@ "megapixels": "megapíxeles", "decimal places": "decimales", "points": "puntos", + "holes": "huecos", "million cells": "millones de celdas", "entries per second": "entradas por segundo", "files": "archivos", diff --git a/web/content/fr/site.json b/web/content/fr/site.json index b6279de4..06697f41 100644 --- a/web/content/fr/site.json +++ b/web/content/fr/site.json @@ -260,6 +260,7 @@ "megapixels": "mégapixels", "decimal places": "décimales", "points": "points", + "holes": "trous", "million cells": "millions de cellules", "entries per second": "entrées par seconde", "files": "fichiers", diff --git a/web/content/hi/site.json b/web/content/hi/site.json index 758b7a52..e6ff9826 100644 --- a/web/content/hi/site.json +++ b/web/content/hi/site.json @@ -260,6 +260,7 @@ "megapixels": "मेगापिक्सेल", "decimal places": "दशमलव स्थान", "points": "बिंदु", + "holes": "छेद", "million cells": "दस लाख सेल", "entries per second": "प्रति सेकंड प्रविष्टियाँ", "files": "फ़ाइलें", diff --git a/web/content/id/site.json b/web/content/id/site.json index 8acf32a1..6212414b 100644 --- a/web/content/id/site.json +++ b/web/content/id/site.json @@ -260,6 +260,7 @@ "megapixels": "megapiksel", "decimal places": "angka desimal", "points": "titik", + "holes": "lubang", "million cells": "juta sel", "entries per second": "entri per detik", "files": "file", diff --git a/web/content/it/site.json b/web/content/it/site.json index 427fdb13..49878827 100644 --- a/web/content/it/site.json +++ b/web/content/it/site.json @@ -260,6 +260,7 @@ "megapixels": "megapixel", "decimal places": "cifre decimali", "points": "punti", + "holes": "fori", "million cells": "milioni di celle", "entries per second": "voci al secondo", "files": "file", diff --git a/web/content/ja/site.json b/web/content/ja/site.json index ddb9fd8f..4d4367c1 100644 --- a/web/content/ja/site.json +++ b/web/content/ja/site.json @@ -260,6 +260,7 @@ "megapixels": "メガピクセル", "decimal places": "小数桁", "points": "点", + "holes": "個の穴", "million cells": "百万セル", "entries per second": "1秒あたりのエントリ数", "files": "ファイル", diff --git a/web/content/ko/site.json b/web/content/ko/site.json index d80cd03e..af6aefdb 100644 --- a/web/content/ko/site.json +++ b/web/content/ko/site.json @@ -260,6 +260,7 @@ "megapixels": "메가픽셀", "decimal places": "소수 자릿수", "points": "점", + "holes": "개의 구멍", "million cells": "백만 셀", "entries per second": "초당 항목 수", "files": "파일", diff --git a/web/content/nl/site.json b/web/content/nl/site.json index fa08aec2..ccf81329 100644 --- a/web/content/nl/site.json +++ b/web/content/nl/site.json @@ -260,6 +260,7 @@ "megapixels": "megapixels", "decimal places": "decimalen", "points": "punten", + "holes": "gaten", "million cells": "miljoen cellen", "entries per second": "items per seconde", "files": "bestanden", diff --git a/web/content/pl/site.json b/web/content/pl/site.json index a5e1b9b5..87cab35e 100644 --- a/web/content/pl/site.json +++ b/web/content/pl/site.json @@ -257,6 +257,7 @@ "megapixels": "megapikseli", "decimal places": "miejsc po przecinku", "points": "punktów", + "holes": "dziur", "million cells": "milionów komórek", "entries per second": "wpisów na sekundę", "files": "plików", diff --git a/web/content/pt-BR/site.json b/web/content/pt-BR/site.json index 66cf7ffb..5bcd0cf9 100644 --- a/web/content/pt-BR/site.json +++ b/web/content/pt-BR/site.json @@ -260,6 +260,7 @@ "megapixels": "megapixels", "decimal places": "casas decimais", "points": "pontos", + "holes": "furos", "million cells": "milhões de células", "entries per second": "entradas por segundo", "files": "arquivos", diff --git a/web/content/ro/site.json b/web/content/ro/site.json index 355e87b3..aaa11aa2 100644 --- a/web/content/ro/site.json +++ b/web/content/ro/site.json @@ -260,6 +260,7 @@ "megapixels": "megapixeli", "decimal places": "zecimale", "points": "puncte", + "holes": "găuri", "million cells": "milioane de celule", "entries per second": "intrări pe secundă", "files": "fișiere", diff --git a/web/content/ru/site.json b/web/content/ru/site.json index 32e6c435..58d6c342 100644 --- a/web/content/ru/site.json +++ b/web/content/ru/site.json @@ -260,6 +260,7 @@ "megapixels": "мегапикселей", "decimal places": "знаков после запятой", "points": "точек", + "holes": "отверстий", "million cells": "миллионов ячеек", "entries per second": "записей в секунду", "files": "файлов", diff --git a/web/content/th/site.json b/web/content/th/site.json index 67406b4c..024845aa 100644 --- a/web/content/th/site.json +++ b/web/content/th/site.json @@ -260,6 +260,7 @@ "megapixels": "เมกะพิกเซล", "decimal places": "ตำแหน่งทศนิยม", "points": "จุด", + "holes": "รู", "million cells": "ล้านเซลล์", "entries per second": "รายการต่อวินาที", "files": "ไฟล์", diff --git a/web/content/tr/site.json b/web/content/tr/site.json index eac4277e..4d4cd3d7 100644 --- a/web/content/tr/site.json +++ b/web/content/tr/site.json @@ -260,6 +260,7 @@ "megapixels": "megapiksel", "decimal places": "ondalık basamak", "points": "nokta", + "holes": "delik", "million cells": "milyon hücre", "entries per second": "saniyedeki girdi", "files": "dosya", diff --git a/web/content/uk/site.json b/web/content/uk/site.json index 30b1d1fe..1365b883 100644 --- a/web/content/uk/site.json +++ b/web/content/uk/site.json @@ -260,6 +260,7 @@ "megapixels": "мегапікселів", "decimal places": "знаків після коми", "points": "точок", + "holes": "отворів", "million cells": "мільйонів комірок", "entries per second": "записів на секунду", "files": "файлів", diff --git a/web/content/vi/site.json b/web/content/vi/site.json index df0122d2..33f89e5f 100644 --- a/web/content/vi/site.json +++ b/web/content/vi/site.json @@ -260,6 +260,7 @@ "megapixels": "megapixel", "decimal places": "chữ số thập phân", "points": "điểm", + "holes": "lỗ", "million cells": "triệu ô", "entries per second": "mục mỗi giây", "files": "tệp", diff --git a/web/content/zh-Hans/site.json b/web/content/zh-Hans/site.json index b41ad933..91be7a2e 100644 --- a/web/content/zh-Hans/site.json +++ b/web/content/zh-Hans/site.json @@ -260,6 +260,7 @@ "megapixels": "百万像素", "decimal places": "位小数", "points": "个点", + "holes": "个洞", "million cells": "百万个单元格", "entries per second": "条目每秒", "files": "个文件", diff --git a/web/content/zh-Hant/site.json b/web/content/zh-Hant/site.json index 07924577..f666f07b 100644 --- a/web/content/zh-Hant/site.json +++ b/web/content/zh-Hant/site.json @@ -260,6 +260,7 @@ "megapixels": "百萬像素", "decimal places": "位小數", "points": "個點", + "holes": "個洞", "million cells": "百萬個儲存格", "entries per second": "每秒項目數", "files": "個檔案", diff --git a/web/public/ar/formats/index.html b/web/public/ar/formats/index.html index b7f41085..d5f11500 100644 --- a/web/public/ar/formats/index.html +++ b/web/public/ar/formats/index.html @@ -528,6 +528,11 @@

الإعدادات التي تقبلها كل صيغة

vertices 3 - 1000000 نقاط + + + holes + 0 - 100000 ثقوب + winding @@ -538,6 +543,21 @@

الإعدادات التي تقبلها كل صيغة

antimeridian صحيح أو خاطئ + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + صحيح أو خاطئ + gif width diff --git a/web/public/cs/formaty/index.html b/web/public/cs/formaty/index.html index adc5b009..9328d60f 100644 --- a/web/public/cs/formaty/index.html +++ b/web/public/cs/formaty/index.html @@ -529,6 +529,11 @@

Nastavení, která každý formát přijímá

vertices 3 - 1000000 bodů + + + holes + 0 - 100000 děr + winding @@ -539,6 +544,21 @@

Nastavení, která každý formát přijímá

antimeridian pravda nebo nepravda + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + pravda nebo nepravda + gif width diff --git a/web/public/de/formate/index.html b/web/public/de/formate/index.html index a0d58e8e..50b74c19 100644 --- a/web/public/de/formate/index.html +++ b/web/public/de/formate/index.html @@ -531,6 +531,11 @@

Einstellungen, die jedes Format kennt

vertices 3 - 1000000 Punkte + + + holes + 0 - 100000 Löcher + winding @@ -541,6 +546,21 @@

Einstellungen, die jedes Format kennt

antimeridian wahr oder falsch + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + wahr oder falsch + gif width diff --git a/web/public/es/formatos/index.html b/web/public/es/formatos/index.html index e43ec92c..068887ec 100644 --- a/web/public/es/formatos/index.html +++ b/web/public/es/formatos/index.html @@ -532,6 +532,11 @@

Ajustes que admite cada formato

vertices 3 - 1000000 puntos + + + holes + 0 - 100000 huecos + winding @@ -542,6 +547,21 @@

Ajustes que admite cada formato

antimeridian verdadero o falso + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + verdadero o falso + gif width diff --git a/web/public/formats/index.html b/web/public/formats/index.html index db8f24ac..a1395f2f 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -530,6 +530,11 @@

Settings each format accepts

vertices 3 - 1000000 points + + + holes + 0 - 100000 holes + winding @@ -540,6 +545,21 @@

Settings each format accepts

antimeridian true or false + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + true or false + gif width diff --git a/web/public/fr/formats/index.html b/web/public/fr/formats/index.html index 109c3599..4a0f9176 100644 --- a/web/public/fr/formats/index.html +++ b/web/public/fr/formats/index.html @@ -532,6 +532,11 @@

Réglages que chaque format accepte

vertices 3 - 1000000 points + + + holes + 0 - 100000 trous + winding @@ -542,6 +547,21 @@

Réglages que chaque format accepte

antimeridian vrai ou faux + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + vrai ou faux + gif width diff --git a/web/public/hi/formats/index.html b/web/public/hi/formats/index.html index 94469fe8..b7d5bb8b 100644 --- a/web/public/hi/formats/index.html +++ b/web/public/hi/formats/index.html @@ -530,6 +530,11 @@

हर फ़ॉर्मैट की स्वीकार की जा vertices 3 - 1000000 बिंदु + + + holes + 0 - 100000 छेद + winding @@ -540,6 +545,21 @@

हर फ़ॉर्मैट की स्वीकार की जा antimeridian सही या गलत + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + सही या गलत + gif width diff --git a/web/public/id/format/index.html b/web/public/id/format/index.html index 7286f037..2092a6df 100644 --- a/web/public/id/format/index.html +++ b/web/public/id/format/index.html @@ -530,6 +530,11 @@

Pengaturan yang diterima setiap format

vertices 3 - 1000000 titik + + + holes + 0 - 100000 lubang + winding @@ -540,6 +545,21 @@

Pengaturan yang diterima setiap format

antimeridian benar atau salah + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + benar atau salah + gif width diff --git a/web/public/it/formati/index.html b/web/public/it/formati/index.html index 488c2eb3..94525a71 100644 --- a/web/public/it/formati/index.html +++ b/web/public/it/formati/index.html @@ -532,6 +532,11 @@

Impostazioni che ogni formato accetta

vertices 3 - 1000000 punti + + + holes + 0 - 100000 fori + winding @@ -542,6 +547,21 @@

Impostazioni che ogni formato accetta

antimeridian vero o falso + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + vero o falso + gif width diff --git a/web/public/ja/formats/index.html b/web/public/ja/formats/index.html index 869d5efa..8d13067d 100644 --- a/web/public/ja/formats/index.html +++ b/web/public/ja/formats/index.html @@ -522,6 +522,11 @@

形式ごとに受け付ける設定

vertices 3 - 1000000 点 + + + holes + 0 - 100000 個の穴 + winding @@ -532,6 +537,21 @@

形式ごとに受け付ける設定

antimeridian 真または偽 + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + 真または偽 + gif width diff --git a/web/public/ko/formats/index.html b/web/public/ko/formats/index.html index 06971acb..b523c215 100644 --- a/web/public/ko/formats/index.html +++ b/web/public/ko/formats/index.html @@ -523,6 +523,11 @@

각 형식이 받는 설정

vertices 3 - 1000000 점 + + + holes + 0 - 100000 개의 구멍 + winding @@ -533,6 +538,21 @@

각 형식이 받는 설정

antimeridian 참 또는 거짓 + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + 참 또는 거짓 + gif width diff --git a/web/public/nl/formaten/index.html b/web/public/nl/formaten/index.html index 18a9206d..d15a4e40 100644 --- a/web/public/nl/formaten/index.html +++ b/web/public/nl/formaten/index.html @@ -532,6 +532,11 @@

Instellingen die elk formaat accepteert

vertices 3 - 1000000 punten + + + holes + 0 - 100000 gaten + winding @@ -542,6 +547,21 @@

Instellingen die elk formaat accepteert

antimeridian waar of onwaar + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + waar of onwaar + gif width diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index ee05b47d..42d570cb 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -530,6 +530,11 @@

Ustawienia, które przyjmuje każdy format

vertices 3 - 1000000 punktów + + + holes + 0 - 100000 dziur + winding @@ -540,6 +545,21 @@

Ustawienia, które przyjmuje każdy format

antimeridian prawda albo fałsz + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + prawda albo fałsz + gif width diff --git a/web/public/pt-br/formatos/index.html b/web/public/pt-br/formatos/index.html index ce0a5973..c4c63456 100644 --- a/web/public/pt-br/formatos/index.html +++ b/web/public/pt-br/formatos/index.html @@ -531,6 +531,11 @@

Configurações que cada formato aceita

vertices 3 - 1000000 pontos + + + holes + 0 - 100000 furos + winding @@ -541,6 +546,21 @@

Configurações que cada formato aceita

antimeridian verdadeiro ou falso + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + verdadeiro ou falso + gif width diff --git a/web/public/ro/formate/index.html b/web/public/ro/formate/index.html index d3718f4c..0a39e8a5 100644 --- a/web/public/ro/formate/index.html +++ b/web/public/ro/formate/index.html @@ -531,6 +531,11 @@

Setările pe care le acceptă fiecare format

vertices 3 - 1000000 puncte + + + holes + 0 - 100000 găuri + winding @@ -541,6 +546,21 @@

Setările pe care le acceptă fiecare format

antimeridian adevărat sau fals + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + adevărat sau fals + gif width diff --git a/web/public/ru/formats/index.html b/web/public/ru/formats/index.html index 0ed6a261..9a3ab22a 100644 --- a/web/public/ru/formats/index.html +++ b/web/public/ru/formats/index.html @@ -530,6 +530,11 @@

Настройки, которые принимает каждый форм vertices 3 - 1000000 точек + + + holes + 0 - 100000 отверстий + winding @@ -540,6 +545,21 @@

Настройки, которые принимает каждый форм antimeridian истина или ложь + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + истина или ложь + gif width diff --git a/web/public/th/formats/index.html b/web/public/th/formats/index.html index 130b74d8..6ca32bfc 100644 --- a/web/public/th/formats/index.html +++ b/web/public/th/formats/index.html @@ -528,6 +528,11 @@

การตั้งค่าที่แต่ละรูปแบบร vertices 3 - 1000000 จุด + + + holes + 0 - 100000 รู + winding @@ -538,6 +543,21 @@

การตั้งค่าที่แต่ละรูปแบบร antimeridian จริงหรือเท็จ + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + จริงหรือเท็จ + gif width diff --git a/web/public/tr/bicimler/index.html b/web/public/tr/bicimler/index.html index 57a362aa..7a32b13b 100644 --- a/web/public/tr/bicimler/index.html +++ b/web/public/tr/bicimler/index.html @@ -530,6 +530,11 @@

Her biçimin kabul ettiği ayarlar

vertices 3 - 1000000 nokta + + + holes + 0 - 100000 delik + winding @@ -540,6 +545,21 @@

Her biçimin kabul ettiği ayarlar

antimeridian doğru veya yanlış + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + doğru veya yanlış + gif width diff --git a/web/public/uk/formats/index.html b/web/public/uk/formats/index.html index cf303568..85de0331 100644 --- a/web/public/uk/formats/index.html +++ b/web/public/uk/formats/index.html @@ -530,6 +530,11 @@

Налаштування, які приймає кожен формат

vertices 3 - 1000000 точок + + + holes + 0 - 100000 отворів + winding @@ -540,6 +545,21 @@

Налаштування, які приймає кожен формат

antimeridian істина або хибність + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + істина або хибність + gif width diff --git a/web/public/vi/dinh-dang/index.html b/web/public/vi/dinh-dang/index.html index 56bf3356..e2e3a9e7 100644 --- a/web/public/vi/dinh-dang/index.html +++ b/web/public/vi/dinh-dang/index.html @@ -528,6 +528,11 @@

Các thiết lập mỗi định dạng nhận

vertices 3 - 1000000 điểm + + + holes + 0 - 100000 lỗ + winding @@ -538,6 +543,21 @@

Các thiết lập mỗi định dạng nhận

antimeridian đúng hoặc sai + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + đúng hoặc sai + gif width diff --git a/web/public/zh-hans/formats/index.html b/web/public/zh-hans/formats/index.html index b980a1b5..35712a41 100644 --- a/web/public/zh-hans/formats/index.html +++ b/web/public/zh-hans/formats/index.html @@ -522,6 +522,11 @@

每种格式接受的设置

vertices 3 - 1000000 个点 + + + holes + 0 - 100000 个洞 + winding @@ -532,6 +537,21 @@

每种格式接受的设置

antimeridian 真或假 + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + 真或假 + gif width diff --git a/web/public/zh-hant/formats/index.html b/web/public/zh-hant/formats/index.html index 792c944a..9fa71e54 100644 --- a/web/public/zh-hant/formats/index.html +++ b/web/public/zh-hant/formats/index.html @@ -522,6 +522,11 @@

每種格式接受的設定

vertices 3 - 1000000 個點 + + + holes + 0 - 100000 個洞 + winding @@ -532,6 +537,21 @@

每種格式接受的設定

antimeridian 真或假 + + + unlocated + all, none, some + + + + ids + mixed, none, number, string + + + + bbox + 真或假 + gif width