From c3d2dfc0576ecaffc3ea7b53a760461901ab04c9 Mon Sep 17 00:00:00 2001 From: Stephen Mildenhall Date: Thu, 26 Jun 2025 08:52:54 +0100 Subject: [PATCH] pre build changes for gtfont --- .gitignore | 3 ++ docs/versions.rst | 4 +-- greater_tables/__init__.py | 5 +--- greater_tables/_version.py | 21 +++++++++++++ greater_tables/config.py | 2 +- greater_tables/core.py | 29 +++++++++++------- greater_tables/etcher.py | 39 ++++++++++++++---------- greater_tables/utilities.py | 2 +- gtfont/Cargo.toml | 17 +++++++++++ gtfont/src/lib.rs | 59 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 ++ 11 files changed, 149 insertions(+), 35 deletions(-) create mode 100644 greater_tables/_version.py create mode 100644 gtfont/Cargo.toml create mode 100644 gtfont/src/lib.rs diff --git a/.gitignore b/.gitignore index e4e9de3..a76be03 100644 --- a/.gitignore +++ b/.gitignore @@ -166,3 +166,6 @@ cython_debug/ #.idea/ docs/books.bib docs/library.bib + +# rust +gtfont/target \ No newline at end of file diff --git a/docs/versions.rst b/docs/versions.rst index 8f24def..78cce21 100644 --- a/docs/versions.rst +++ b/docs/versions.rst @@ -1,5 +1,5 @@ -Versions -========== +Versions and Change Log +========================== 5.0.0 ------- diff --git a/greater_tables/__init__.py b/greater_tables/__init__.py index 2cea140..189340f 100644 --- a/greater_tables/__init__.py +++ b/greater_tables/__init__.py @@ -1,10 +1,7 @@ -__version__ = '5.0.0' __project__ = 'greater_tables' __author__ = 'Stephen J Mildenhall' +from . _version import __version__ from . core import GT from . fabrications import Fabricator from . etcher import Etcher - -# from . gtbreaks import Breakability - diff --git a/greater_tables/_version.py b/greater_tables/_version.py new file mode 100644 index 0000000..0079903 --- /dev/null +++ b/greater_tables/_version.py @@ -0,0 +1,21 @@ +# file generated by setuptools-scm +# don't change, don't track in version control + +__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"] + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Tuple + from typing import Union + + VERSION_TUPLE = Tuple[Union[int, str], ...] +else: + VERSION_TUPLE = object + +version: str +__version__: str +__version_tuple__: VERSION_TUPLE +version_tuple: VERSION_TUPLE + +__version__ = version = '5.0.0' +__version_tuple__ = version_tuple = (5, 0, 0) diff --git a/greater_tables/config.py b/greater_tables/config.py index b80a749..96e3994 100644 --- a/greater_tables/config.py +++ b/greater_tables/config.py @@ -51,7 +51,7 @@ class Configurator(BaseModel): default_ratio_str: str = Field( "{x:.1%}", description="Format f-string for ratios. Example: '{x:.1%}'" ) - default_formatter: Optional[str] = Field( + default_formatter: Optional[Union[str, Callable[[Any, str], str]]] = Field( None, description="Optional fallback formatter f-string" ) diff --git a/greater_tables/core.py b/greater_tables/core.py index ed30153..3dda2d4 100644 --- a/greater_tables/core.py +++ b/greater_tables/core.py @@ -376,14 +376,14 @@ class GT(object): # figure the default formatter (used in conjunction with raw columns) if self.config.default_formatter is None: - self.default_formatter = self.default_formatter + self.default_formatter = self._default_formatter else: assert callable( - config.default_formatter), 'config.default_formatter must be callable' + self.config.default_formatter), 'config.default_formatter must be callable' def wrapped_default_formatter(x): try: - return config.default_formatter(x) + return self.config.default_formatter(x) except ValueError: return str(x) self.default_formatter = wrapped_default_formatter @@ -513,9 +513,9 @@ class GT(object): if tabs is None: self.tabs = None elif isinstance(tabs, (int, float)): - self.tabs = (tabs,) * self.ncols + self.tabs = (tabs,) * (self.nindex + self.ncols) elif isinstance(tabs, (np.ndarray, pd.Series, list, tuple)): - if len(tabs) == self.ncols: + if len(tabs) == self.nindex + self.ncols: self.tabs = tabs # Already iterable and right length, self.tabs = as is else: logger.error( @@ -579,7 +579,7 @@ class GT(object): if self.config.tikz_escape_tex: self.df_tex = Escaping.escape_df_tex(self.df) else: - self.df_tex + self.df_tex = self.df def __repr__(self): """Basic representation.""" @@ -710,8 +710,8 @@ class GT(object): logger.debug(f'AttributeError {e}') return str(x) - def default_formatter(self, x): - """Default universal formatter for other types (GTP re-write of above cluster).""" + def _default_formatter(self, x): + """Default universal formatter for other types.""" try: f = float(x) except (TypeError, ValueError): @@ -920,7 +920,12 @@ class GT(object): def tex_knowledge_df(self): """Uber source of information for tex formatting.""" if self._tex_knowledge_df is None: - self._tex_knowledge_df = self.estimate_column_widths_by_mode('tex') + if (all(self.df_tex.index == self.df_html.index) + and all(self.df_tex.columns == self.df_html.columns) + and all(self.df_tex == self.df_html)): + self._tex_knowledge_df = self.html_knowledge_df + else: + self._tex_knowledge_df = self.estimate_column_widths_by_mode('tex') return self._tex_knowledge_df def width_report(self): @@ -959,7 +964,7 @@ class GT(object): return bit def estimate_column_widths_by_mode(self, mode): - """ + r""" Return dataframe of width information: three modes for text, html, and tex. Mode adjusts which df is used and how widths are estimated @@ -2027,7 +2032,9 @@ class GT(object): def make_svg(self): """Render tikz into svg text.""" tz = Etcher(self._repr_latex_(), - file_name=self.df_id, debug=self.config.debug) + self.config.table_font_pt_size, + file_name=self.df_id + ) p = tz.file_path.with_suffix('.svg') if not p.exists(): try: diff --git a/greater_tables/etcher.py b/greater_tables/etcher.py index c6ad1e4..8b7d937 100644 --- a/greater_tables/etcher.py +++ b/greater_tables/etcher.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) class Etcher: """Create PDF and SVG files from Tikz blocks.""" # Full TeX preamble to generate a .fmt if needed - _tex_template_full = r"""\documentclass[10pt, border=5mm]{standalone} + _tex_template_full = r"""\documentclass[11pt, border=5mm]{standalone} \usepackage{newtxtext,newtxmath} % gpt recommended like STIX %\usepackage{mathptmx} % gpt like times roman \usepackage{amsfonts} @@ -44,28 +44,36 @@ class Etcher: \newcommand{{\I}}{{\vphantom{{lp}}}} % fka grtspacer \def\dfrac{{\displaystyle\frac}} \def\dint{{\displaystyle\int}} + \begin{{document}} + {tikz_begin}{tikz_code}{tikz_end} + \end{{document}} """ - def __init__(self, txt, file_name='', base_path='.', tex_engine='pdflatex', debug=False): + def __init__(self, txt, font_size=11, file_name='', base_path='.', tex_engine='pdflatex'): """Create object from txt, a TeX blob containing a tikzpicture.""" self.txt = txt + self.font_size = font_size self.tex_engine = tex_engine self.base_path = Path(base_path).resolve() self.out_path = self.base_path / 'tikz' self.out_path.mkdir(exist_ok=True) file_name = file_name or txt_short_hash(txt) self.file_path = self.out_path / file_name - self.format_file = self.out_path / 'tikz_format.fmt' - self.debug = debug + self.format_file = self.out_path / f'tikz_format-{self.font_size}.fmt' def split_tikz(self): """Split text to extract the TikZ picture.""" return re.split(r'(\\begin{tikz(?:cd|picture)}|\\end{tikz(?:cd|picture)})', self.txt) + def unlink_format_file(self): + """Unlink the format file to force a rebuild.""" + if self.format_file.exists(): + self.format_file.unlink() + def ensure_format_file(self): """Create format file for faster compilation if missing.""" if self.format_file.exists(): @@ -84,8 +92,10 @@ class Etcher: (self.file_path.parent / 'make_format.bat').write_text(" ".join(cmd), encoding='utf-8') self.run_command(cmd, raise_on_error=True, cwd=self.out_path) # tidy up ... to some extent - # tmp.unlink() - (self.out_path / f'{self.format_file.stem}.log').unlink() + for ext in ('.aux', '.log'): + path = tmp.with_suffix(ext) + if path.exists(): + path.unlink() logger.info('...success...format file built', self.format_file.resolve()) def process_tikz(self): @@ -112,8 +122,7 @@ class Etcher: str(tex_path) ] (tex_path.parent / 'make_tikz.bat').write_text(" ".join(tex_cmd), encoding='utf-8') - if self.debug: - logger.info("Running:", " ".join(tex_cmd)) + logger.info("Running:", " ".join(tex_cmd)) if self.run_command(tex_cmd): raise ValueError('TeX failed to compile, not pdf or svg output.') # no tidying up @@ -125,15 +134,13 @@ class Etcher: str(pdf_path), str(svg_path) ] - if self.debug: - logger.info("Running:", " ".join(svg_cmd)) + logger.info("Running:", " ".join(svg_cmd)) self.run_command(svg_cmd, raise_on_error=True) - if not self.debug: - for ext in ('.tex', '.aux', '.log', '.pdf'): - path = tex_path.with_suffix(ext) - if path.exists(): - path.unlink() + for ext in ('.aux', '.log', '.pdf'): + path = tex_path.with_suffix(ext) + if path.exists(): + path.unlink() def display(self): """Display the SVG in Jupyter.""" @@ -143,7 +150,7 @@ class Etcher: """Run command with subprocess and show output.""" with Popen(command, cwd=cwd, stdout=PIPE, stderr=PIPE, universal_newlines=True) as p: stdout, stderr = p.communicate() - if stdout and self.debug: + if stdout: logger.info('Run command output ends\n', stdout.strip()[-250:]) if stdout: if stdout.find('no output PDF file produced') > 0: diff --git a/greater_tables/utilities.py b/greater_tables/utilities.py index 85ad082..82f33c5 100644 --- a/greater_tables/utilities.py +++ b/greater_tables/utilities.py @@ -302,7 +302,7 @@ class TextLength: "‘": 0.333, "{": 0.48, "}": 0.48, - "-": 0.333, + "-": 0.5, # 0.333, } char_width = {c: w for chars, w in width_table.items() for c in chars} return char_width.get(c, 0.6) diff --git a/gtfont/Cargo.toml b/gtfont/Cargo.toml new file mode 100644 index 0000000..a74f031 --- /dev/null +++ b/gtfont/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gtfont" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.21", features = ["extension-module", "abi3-py38"] } +fontdue = "0.8" + +[package.metadata.maturin] +name = "gtfont" + +[env] +PYO3_USE_ABI3_FORWARD_COMPATIBILITY = "1" diff --git a/gtfont/src/lib.rs b/gtfont/src/lib.rs new file mode 100644 index 0000000..d8f27ee --- /dev/null +++ b/gtfont/src/lib.rs @@ -0,0 +1,59 @@ +use fontdue::Font; +use pyo3::prelude::*; + +#[pyclass] +struct FontMeasurer { + font: Font, +} + +#[pymethods] +impl FontMeasurer { + #[new] + fn new(font_bytes: &[u8]) -> PyResult { + let font = Font::from_bytes(font_bytes, fontdue::FontSettings::default()) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("Font error: {e}")))?; + Ok(Self { font }) + } + + fn measure(&self, text: &str, px: f32) -> f32 { + text.chars() + .map(|c| self.font.metrics(c, px).advance_width) + .sum() + } + + fn max_word_width(&self, text: &str, px: f32) -> f32 { + text.split(' ') + .map(|word| word.chars() + .map(|c| self.font.metrics(c, px).advance_width) + .sum::()) + .fold(0.0, f32::max) + } + fn measure_and_max_word(&self, text: &str, px: f32) -> (f32, f32) { + let mut total: f32 = 0.0; + let mut max_word: f32 = 0.0; + + for word in text.split(' ') { + let word_width: f32 = word.chars() + .map(|c| self.font.metrics(c, px).advance_width) + .sum(); + total += word_width + self.font.metrics(' ', px).advance_width; + max_word = max_word.max(word_width); + } + + if text.ends_with(' ') { + // trailing space is valid + } else if total > 0.0 { + total -= self.font.metrics(' ', px).advance_width; + } + + (total, max_word) + } + + +} + +#[pymodule] +fn gtfont(_py: &Bound<'_, PyModule>) -> PyResult<()> { + _py.add_class::()?; + Ok(()) +} diff --git a/pyproject.toml b/pyproject.toml index bf860b8..df5046c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ write_to = "greater_tables/_version.py" version_scheme = "post-release" local_scheme = "node-and-date" # "no-local-version" + [tool.setuptools.packages.find] include = ["greater_tables", "greater_tables.data"] exclude = ["img", "tests", "docs"] @@ -48,6 +49,8 @@ version = { attr = "greater_tables.__version__" } [project.urls] "Source Code" = "https://github.com/mynl/greater_tables_project" +"Documentation" = "https://greater-tables-project.readthedocs.io/en/latest/" +"Changelog" = "https://greater-tables-project.readthedocs.io/en/latest/versions.html" [project.optional-dependencies] dev = [