diff --git a/README.md b/README.md index f89dfb0..df63ae9 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,10 @@ code.print_code(language="c") The `pack_table` function accepts: - A list of integers, or a dict mapping integer keys to values -- `default`: value for missing keys (default `0`); pass `None` for list - input to try both boundary values and merge the resulting Pareto frontier +- `default`: value for missing keys (default `0`). `None` is treated as a + literal value (e.g. mapped to an integer via `mapping`); pass the sentinel + `NotImplemented` for list input to infer the default from the boundary + values, trying both and merging the resulting Pareto frontier - `compression`: tuning knob with sentinel endpoints: `0` prefers flat encoding, `1..9` use the size/speed heuristic, and `10` minimizes raw table bytes (default `1`) diff --git a/packTab/__init__.py b/packTab/__init__.py index 9d09cdd..37854a6 100644 --- a/packTab/__init__.py +++ b/packTab/__init__.py @@ -656,7 +656,13 @@ def print_code( def print_c(self, file=sys.stdout, indent=0): self.print_code(file=file, indent=indent, language="c") - def print_h(self, file=sys.stdout, linkage="", indent=0): + def print_h(self, file=sys.stdout, linkage="extern", indent=0): + """Emit C prototypes for the public (non-private) functions. + + A companion to ``print_c`` for generating a header. Private helpers + (e.g. sub-byte accessors) are ``static`` and belong with the + definitions, so they are skipped here. + """ if linkage: linkage += " " if isinstance(indent, int): @@ -665,9 +671,10 @@ def print_h(self, file=sys.stdout, linkage="", indent=0): println = partial(printn, indent) for name, function in self.functions.items(): - link = (linkage if function.linkage is None else function.linkage) + " " + if function.private: + continue args = ", ".join(" ".join(p) for p in function.args) - println("%s%s %s (%s);" % (link, function.retType, name, args)) + println("%s%s %s (%s);" % (linkage, function.retType, name, args)) # Cost model constants. These tune the tradeoff between table size @@ -1624,7 +1631,7 @@ def _try_palette_encoding(self, data, extraCost): def pack_table( data: Union[List[Union[int, str]], Dict[int, Union[int, str]]], - default: Optional[Union[int, str]] = 0, + default: Any = 0, compression: Optional[float] = 1, mapping: Optional[Dict[Any, Any]] = None, ) -> Union["OuterSolution", List["OuterSolution"]]: @@ -1634,10 +1641,13 @@ def pack_table( data: Either a dictionary mapping integer keys to values, or an iterable containing values for keys starting at zero. Values must all be integers, or all strings. - default: Value to be used for keys not specified in data. If None, - tries both boundary values and keeps the combined Pareto frontier. - Inferred defaults are only supported for list input. Defaults - to zero. + default: Value to be used for keys not specified in data (and returned + for out-of-range lookups). May be any value, including ``None``, + which is treated as a literal value (e.g. mapped to an integer via + ``mapping``). Pass the sentinel ``NotImplemented`` to instead infer + the default from the boundary values, trying both and keeping the + combined Pareto frontier; inferred defaults are only supported for + list input. Defaults to zero. compression: Tunes the size-vs-speed tradeoff. Higher values prefer smaller tables. If None, returns all Pareto-optimal solutions. Defaults to 1. @@ -1698,8 +1708,10 @@ def pack_table( # Set up data as a list. if isinstance(data, dict): - if default is None: - raise ValueError("default=None is only supported for list input") + if default is NotImplemented: + raise ValueError( + "inferred default (NotImplemented) is only supported for list input" + ) if not all(isinstance(k, int) for k in data.keys()): raise TypeError("dict keys must be integers") minK = min(data.keys()) @@ -1720,7 +1732,11 @@ def pack_table( raise TypeError("data values must be all integers or all non-integers") if not isinstance(data[0], int) and mapping is not None: data = [mapping[v] for v in data] - if not isinstance(default, int) and mapping is not None: + if ( + default is not NotImplemented + and not isinstance(default, int) + and mapping is not None + ): default = mapping[default] def build_solutions_for_default(default_value): @@ -1746,7 +1762,7 @@ def build_solutions_for_default(default_value): return solutions - if default is None: + if default is NotImplemented: defaults = [data[0]] if data[-1] != data[0]: defaults.append(data[-1]) diff --git a/packTab/test.py b/packTab/test.py index f237e55..ea2461c 100644 --- a/packTab/test.py +++ b/packTab/test.py @@ -450,6 +450,29 @@ def test_print_code_public(self): code.print_code(file=buf, language="c", private=False) assert "extern const" in buf.getvalue() + def test_print_h_public_prototype(self): + code = Code("data") + pack_table(list(range(300)), default=0, compression=1).genCode( + code, "get", language="c", private=False + ) + buf = io.StringIO() + code.print_h(file=buf) + out = buf.getvalue() + assert "extern uint16_t data_get (unsigned u);" in out + # Private sub-byte helpers must not appear in the header. + assert "data_b" not in out + + def test_print_h_skips_private_helpers(self): + code = Code("t") + code.addFunction("uint8_t", "helper", (("unsigned", "u"),), "u", private=True) + code.addFunction( + "uint8_t", "pub", (("unsigned", "u"),), "u", private=False + ) + buf = io.StringIO() + code.print_h(file=buf) + out = buf.getvalue() + assert "t_pub" in out and "t_helper" not in out + # ── Core algorithm ───────────────────────────────────────────────── @@ -1497,10 +1520,11 @@ def test_palette_separate_from_other_arrays(self): class TestInferredDefault: - def test_none_default_uses_boundary_candidates(self): + # Inference is requested with the NotImplemented sentinel; None is a literal. + def test_infer_uses_boundary_candidates(self): data = [9, 1, 2, 3, 0, 0, 0] - inferred = pack_table(data, default=None, compression=None) + inferred = pack_table(data, default=NotImplemented, compression=None) explicit = pack_table(data, default=0, compression=None) assert any( @@ -1509,10 +1533,10 @@ def test_none_default_uses_boundary_candidates(self): for b in explicit ) - def test_none_default_considers_both_boundary_values(self): + def test_infer_considers_both_boundary_values(self): data = [7, 0, 0, 0, 1] - inferred = pack_table(data, default=None, compression=None) + inferred = pack_table(data, default=NotImplemented, compression=None) left = pack_table(data, default=7, compression=None) right = pack_table(data, default=1, compression=None) @@ -1524,6 +1548,36 @@ def test_none_default_considers_both_boundary_values(self): assert inferred_costs <= candidate_costs assert inferred_costs + def test_infer_not_supported_for_dict(self): + with pytest.raises(ValueError): + pack_table({0: 1, 5: 2}, default=NotImplemented) + + def test_none_is_a_literal_value_not_inference(self): + # None must be usable as a real value (mapped to an int), the way + # HarfBuzz's decomposition table uses it: dict + default=None + + # a mapping that sends None -> 0. + mapping = {None: 0, "a": 1, "b": 2} + data = {0: "a", 3: "b"} # gaps (1, 2) fall back to default None -> 0 + sol = pack_table(data, default=None, mapping=mapping, compression=1) + code = Code("data") + sol.genCode(code, "get", language="c", private=False) + buf = io.StringIO() + code.print_code(file=buf, language="c") + gen = buf.getvalue() + # Out-of-range / gap default is 0 (mapped from None), not a crash. + assert "data_get" in gen + _compile_and_run(gen, [1, 0, 0, 2], 0, "c") + + def test_none_literal_list_with_mapping(self): + mapping = {None: 0, "x": 5} + data = ["x", None, "x"] + sol = pack_table(data, default=None, mapping=mapping, compression=1) + code = Code("data") + sol.genCode(code, "get", language="c", private=False) + buf = io.StringIO() + code.print_code(file=buf, language="c") + _compile_and_run(buf.getvalue(), [5, 0, 5], 0, "c") + class TestCodegenSoundnessRegression: """Round-trip regressions for codegen soundness bugs found by fuzzing."""