Files
tippecanoe/text.cpp
T
Claude 7127e49c86 Replace the Grisu2 float formatter with a C++ port of rsc/fpfmt
Tippecanoe formatted every double it wrote through milo::dtoa_milo, a
vendored Grisu2. Grisu2 is fast, but it guarantees neither the shortest
digit string nor the correctly rounded one: it only guarantees that what
it prints parses back to the value it came from. In practice it prints a
digit more than necessary about 0.16% of the time, and picks a neighbor
of the correctly rounded digits about 32% of the time.

This ports Russ Cox's fpfmt (https://github.com/rsc/fpfmt) to C++ in
fpfmt/ and formats through it instead. fpfmt is both shortest and
correctly rounded, and it is faster:

  full std::string formatting     Grisu2      fpfmt   speedup
  random bit patterns          156.62 ns   66.83 ns     2.34x
  geo coordinates              124.07 ns   58.62 ns     2.12x
  short decimals                69.37 ns   49.16 ns     1.41x
  small integers                44.18 ns   38.06 ns     1.16x

  digit generation only           Grisu2      fpfmt   speedup
  random bit patterns           90.07 ns   20.81 ns     4.33x
  geo coordinates               80.64 ns   20.18 ns     4.00x
  short decimals                55.61 ns   21.90 ns     2.54x
  small integers                40.23 ns   22.50 ns     1.79x

(Intel Xeon @ 2.80GHz, g++ 13.3 -O3. `make fpfmt-bench` reproduces this,
and `./fpfmt-bench -check` reruns the correctness sweep, which is why
milo/dtoa_milo.h is kept even though nothing links it any more.)

The port is deliberately literal, so it can be diffed against fpfmt.go.
Its Short() agrees bit for bit with the Go original's on 445,640 values
covering powers of ten, small integers and reciprocals, subnormals, and
random bit patterns. Over 38.5 million values, fpfmt::dtoa always round
trips, is never longer than Grisu2's output, and is shorter 61,329 times.

Output is otherwise formatted exactly as before, including the choice
between plain and exponential notation, so 26 expected test outputs
change: some numbers lose digits (-26.170044999999999 becomes
-26.170045), and some have a corrected final digit (9.823748927348929e+55
becomes 9.823748927348928e+55). Every changed token was checked to parse
back to the identical double; none of the values themselves moved.

milo/milo.h, whose only job was to declare the C shim jsonpull calls, is
replaced by fpfmt/fpfmt.h, and the shim is renamed dtoa_shortest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wJRAuhMninQE4wK2TUfuZ
2026-08-31 00:22:35 +00:00

358 lines
8.5 KiB
C++

#include <stdio.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <vector>
#include "text.hpp"
#include "fpfmt/fpfmt.hpp"
#include "fpfmt/fpfmt.h"
#include "errors.hpp"
/**
* Returns an empty string if `s` is valid utf8;
* otherwise returns an error message.
*/
std::string check_utf8(std::string s) {
for (size_t i = 0; i < s.size(); i++) {
size_t fail = 0;
if ((s[i] & 0x80) == 0x80) {
if ((s[i] & 0xE0) == 0xC0) {
if (i + 1 >= s.size() || (s[i + 1] & 0xC0) != 0x80) {
fail = 2;
} else {
i += 1;
}
} else if ((s[i] & 0xF0) == 0xE0) {
if (i + 2 >= s.size() || (s[i + 1] & 0xC0) != 0x80 || (s[i + 2] & 0xC0) != 0x80) {
fail = 3;
} else {
i += 2;
}
} else if ((s[i] & 0xF8) == 0xF0) {
if (i + 3 >= s.size() || (s[i + 1] & 0xC0) != 0x80 || (s[i + 2] & 0xC0) != 0x80 || (s[i + 3] & 0xC0) != 0x80) {
fail = 4;
} else {
i += 3;
}
} else {
fail = 1;
}
}
if (fail != 0) {
std::string out = "\"" + s + "\" is not valid UTF-8 (";
for (size_t j = 0; j < fail && i + j < s.size(); j++) {
if (j != 0) {
out += " ";
}
char tmp[6];
snprintf(tmp, sizeof(tmp), "0x%02X", s[i + j] & 0xFF);
out += std::string(tmp);
}
out += ")";
return out;
}
}
return "";
}
const char *utf8_next(const char *s, long *c) {
if (s == NULL) {
*c = -1;
return NULL;
}
if (*s == '\0') {
*c = -1;
return NULL;
}
if ((s[0] & 0x80) == 0x80) {
if ((s[0] & 0xE0) == 0xC0) {
if ((s[1] & 0xC0) != 0x80) {
*c = 0xFFFD;
s++;
} else {
*c = ((long) (s[0] & 0x1F) << 6) | ((long) (s[1] & 0x7F));
s += 2;
}
} else if ((s[0] & 0xF0) == 0xE0) {
if ((s[1] & 0xC0) != 0x80 || (s[2] & 0xC0) != 0x80) {
*c = 0xFFFD;
s++;
} else {
*c = ((long) (s[0] & 0x0F) << 12) | ((long) (s[1] & 0x7F) << 6) | ((long) (s[2] & 0x7F));
s += 3;
}
} else if ((s[0] & 0xF8) == 0xF0) {
if ((s[1] & 0xC0) != 0x80 || (s[2] & 0xC0) != 0x80 || (s[3] & 0xC0) != 0x80) {
*c = 0xFFFD;
s++;
} else {
*c = ((long) (s[0] & 0x0F) << 18) | ((long) (s[1] & 0x7F) << 12) | ((long) (s[2] & 0x7F) << 6) | ((long) (s[3] & 0x7F));
s += 4;
}
} else {
*c = 0xFFFD;
s++;
}
} else {
*c = s[0];
s++;
}
return s;
}
std::string truncate16(std::string const &s, size_t runes) {
const char *cp = s.c_str();
const char *start = cp;
const char *lastgood = cp;
size_t len = 0;
long c;
while ((cp = utf8_next(cp, &c)) != NULL) {
if (c <= 0xFFFF) {
len++;
} else {
len += 2;
}
if (len <= runes) {
lastgood = cp;
} else {
break;
}
}
return std::string(s, 0, lastgood - start);
}
int integer_zoom(std::string where, std::string text) {
double d = atof(text.c_str());
if (!std::isfinite(d) || d != floor(d) || d < 0 || d > 32) {
fprintf(stderr, "%s: Expected integer zoom level in \"tippecanoe\" GeoJSON extension, not %s\n", where.c_str(), text.c_str());
exit(EXIT_JSON);
}
return d;
}
std::string format_commandline(int argc, char **argv) {
std::string out;
for (int i = 0; i < argc; i++) {
bool need_quote = false;
for (char *cp = argv[i]; *cp != '\0'; cp++) {
if (!isalpha(*cp) && !isdigit(*cp) &&
*cp != '/' && *cp != '-' && *cp != '_' && *cp != '@' && *cp != ':' &&
*cp != '.' && *cp != '%' && *cp != ',') {
need_quote = true;
break;
}
}
if (need_quote) {
out.push_back('\'');
for (char *cp = argv[i]; *cp != '\0'; cp++) {
if (*cp == '\'') {
out.append("'\"'\"'");
} else {
out.push_back(*cp);
}
}
out.push_back('\'');
} else {
out.append(argv[i]);
}
if (i + 1 < argc) {
out.push_back(' ');
}
}
return out;
}
// for jsonpull to call from C
char *dtoa_shortest(double val) {
std::string s = fpfmt::dtoa(val);
char *dup = strdup(s.c_str());
if (dup == NULL) {
perror("strdup");
exit(EXIT_MEMORY);
}
return dup;
}
// to work with data from https://github.com/kmike/text-unidecode
std::vector<std::string> read_unidecode(const char *fname) {
std::string data;
FILE *f = fopen(fname, "rb");
if (f == NULL) {
perror(fname);
exit(EXIT_OPEN);
}
std::string buf;
buf.resize(2000);
while (true) {
size_t nread = fread((void *) buf.c_str(), sizeof(char), buf.size(), f);
if (nread == 0) {
break;
}
data.append(buf.c_str(), nread);
}
fclose(f);
std::vector<std::string> out;
out.emplace_back(); // because the data file is 1-indexed
out.emplace_back(); // ascii 001
for (size_t i = 0; i < data.size(); i++) {
if (data[i] == '\0') {
out.emplace_back();
} else {
if (data[i] >= '\0' && data[i] <= '~') {
data[i] = tolower(data[i]);
}
out.back().push_back(data[i]);
}
}
return out;
}
std::string unidecode_smash(std::vector<std::string> const &unidecode_data, const char *s) {
if (unidecode_data.size() == 0) {
return s;
}
std::string out;
out.reserve(strlen(s));
long c;
while (true) {
const char *os = s;
s = utf8_next(s, &c);
if (s == NULL) {
break;
}
if (c >= 0 && c < (long) unidecode_data.size()) {
out.append(unidecode_data[c]);
} else {
// pass through anything that is out of unidecode range literally
for (; os != s; os++) {
out.push_back(*os);
}
}
}
return out;
}
unsigned long long fnv1a(std::string const &s) {
// Store tiles by a hash of their contents (fnv1a 64-bit)
// http://www.isthe.com/chongo/tech/comp/fnv/
const unsigned long long fnv_offset_basis = 14695981039346656037u;
const unsigned long long fnv_prime = 1099511628211u;
unsigned long long h = fnv_offset_basis;
for (size_t i = 0; i < s.size(); i++) {
h ^= (unsigned char) s[i];
h *= fnv_prime;
}
return h;
}
// The "additional" is to make it easier to hash a serial_val attribute value and type together
unsigned long long fnv1a(const char *s, char additional) {
// http://www.isthe.com/chongo/tech/comp/fnv/
const unsigned long long fnv_offset_basis = 14695981039346656037u;
const unsigned long long fnv_prime = 1099511628211u;
unsigned long long h = fnv_offset_basis;
for (size_t i = 0; s[i] != '\0'; i++) {
h ^= (unsigned char) s[i];
h *= fnv_prime;
}
h ^= (unsigned char) additional;
h *= fnv_prime;
return h;
}
unsigned long long fnv1a(size_t size, void *p) {
// http://www.isthe.com/chongo/tech/comp/fnv/
unsigned char *s = (unsigned char *) p;
const unsigned long long fnv_offset_basis = 14695981039346656037u;
const unsigned long long fnv_prime = 1099511628211u;
unsigned long long h = fnv_offset_basis;
for (size_t i = 0; i < size; i++) {
h ^= (unsigned char) s[i];
h *= fnv_prime;
}
return h;
}
// This function reverses the order of the bits in a 64-bit word.
// Instead of shifting individual bits in a loop, it shifts them
// in blocks, starting with swapping the halfwords, and working downward
// until it is swapping individual pairs of adjacent bits.
//
// The purpose is to permute the order in which features are visited:
// instead of working in an orderly fashion from the top left to the
// bottom right of the tile, instead jump around to minimize adjacency,
// like a hash function, but taking advantage of the knowledge that we
// are operating on a fixed-size input that can be directly inverted.
// https://en.wikipedia.org/wiki/Bit-reversal_permutation
//
// This allows calculating an appropriate set of features to appear
// at a fractional zoom level: at what is effectively z4.25, for example,
// we can bring in a quarter of the features that will be added in the
// transition from z4 to z5, and have them be spatially distributed
// across the tile rather than clumped together.
unsigned long long bit_reverse(unsigned long long v) {
v = ((v & 0x00000000FFFFFFFF) << 32) | ((v & 0xFFFFFFFF00000000) >> 32);
v = ((v & 0x0000FFFF0000FFFF) << 16) | ((v & 0xFFFF0000FFFF0000) >> 16);
v = ((v & 0x00FF00FF00FF00FF) << 8) | ((v & 0xFF00FF00FF00FF00) >> 8);
v = ((v & 0x0F0F0F0F0F0F0F0F) << 4) | ((v & 0xF0F0F0F0F0F0F0F0) >> 4);
v = ((v & 0x3333333333333333) << 2) | ((v & 0xCCCCCCCCCCCCCCCC) >> 2);
v = ((v & 0x5555555555555555) << 1) | ((v & 0xAAAAAAAAAAAAAAAA) >> 1);
return v;
}
std::string truncate_string(std::string const &s, size_t len) {
if (s.length() <= len) {
return s;
}
// find the initial byte of a UTF-8 character
ssize_t i;
for (i = len; i > 0; i--) {
if ((s[i] & 0x80) == 0 || (s[i] & 0xC0) == 0xC0) {
break;
}
}
return s.substr(0, i);
}
bool starts_with(std::string const &s, std::string const &prefix) {
if (s.size() < prefix.size()) {
return false;
}
for (size_t i = 0; i < prefix.size(); i++) {
if (s[i] != prefix[i]) {
return false;
}
}
return true;
}