diff options
25 files changed, 806 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f9081a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Temporary/reference files +blog-torture/ +old_pages/ +old_media/ + +# Generated site +public/
\ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..382bf14 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# redman.xyz — 2026 modernization + +redman.xyz is my personal website and a technical blog. Currently it runs on DokuWiki. I've downloaded all text to `old_pages/` and embedded images to `old_media/`. + +The goal of this project is switching over to Typst using their experimental HTML output mode. `blog-torture/` contains a reference document that renders correctly, including equations. + +## Repo structure + +`blog-torture/`, `old_pages/`, `old_media/` – temp files to be removed after migration + +`site/` — source .typ files +`static/` — CSS, images, etc. +`public/` — ready-to-publish site goes here + +## Migration + +Keep text verbatim. If there are any typos or other errors, point them out in the chat and keep them. + +`=` corresponds to `<h2>`. It's a level below the title, so use it for sections. + +For internal links, use `#link-self(page)[...])` from `common.typ`. The argument is a path relative to `site/content/` without the file extension. + +`static(path)` returns the URL to a file in `static/`. Path is relative to that directory. + +Replace links to scientific articles with references to the bibliography (`refs.bib`). + +Use `img(url)` from `common.typ` for `<img>`. + +## Building + +``` +uv run build.py +``` diff --git a/build.py b/build.py new file mode 100644 index 0000000..5d4a5b3 --- /dev/null +++ b/build.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +import json +import os +import re +import shutil +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +import jinja2 +import typst + + +@dataclass +class Metadata: + title: str + date: datetime + + +def main() -> None: + repo_dir = Path(__file__).parent + site_dir = repo_dir / 'site' + content_dir = site_dir / 'content' + public_dir = repo_dir / 'public' + + shutil.rmtree(public_dir, ignore_errors=True) + shutil.copytree(repo_dir / 'static', public_dir / 'static') + + os.environ['TYPST_FEATURES'] = 'html' + + pages: dict[Path, Metadata] = {} + + for path in content_dir.rglob('*.typ'): + print(path) + + output_path = public_dir / path.relative_to(content_dir).with_suffix('.html') + output_path.parent.mkdir(parents=True, exist_ok=True) + + metadata = json.loads(typst.query( + root=site_dir, + input=path, + selector='<post-meta>', + field='value', + one=True, + )) + + output_url = str(output_path.relative_to(public_dir)) + + pages[output_url] = Metadata( + title=metadata['title'], + date=datetime( + year=metadata['date_year'], + month=metadata['date_month'], + day=metadata['date_day'], + ), + ) + + typst.compile( + root=site_dir, + input=path, + output=output_path + ) + + print(pages) + + template = jinja2.Template(open(site_dir / 'index.html').read()) + open(public_dir / 'index.html', 'w').write(template.render(pages=pages)) + + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ae7eef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "redman-xyz-2026" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = [ + "typst", + "jinja2", +] diff --git a/site/common.typ b/site/common.typ new file mode 100644 index 0000000..f7a12b3 --- /dev/null +++ b/site/common.typ @@ -0,0 +1,95 @@ + +#let webroot = "/" + +#let format-date-en(date) = { + let months = ( + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ) + + [#months.at(date.month() - 1) #date.day(), #date.year()] +} + +#let link-self(page, body) = { + link(webroot + page + ".html", body) +} + +#let static(path) = { + webroot + "static/" + path +} + +#let img(src) = { + html.elem("img", attrs: (src: src, alt: "")) +} + +#let post(body, title: none, author: "Paweł Redman", date: none) = { + assert(date != none, message: "missing date") + + set document( + title: title, + author: "Paweł Redman", + ) + + set text(lang: "en") + set heading(numbering: "1.1.") + set math.equation(numbering: "(1.)") + + // As of 0.14, equations aren't supported in HTML. Emit them as SVG instead. + show math.equation: it => context { + if target() == "html" { + show: if it.block { it => it } else { box } + html.frame(it) + } else { + it + } + } + + [ + #metadata(( + title: title, + date_year: date.year(), + date_month: date.month(), + date_day: date.day(), + )) <post-meta> + ] + + html.html({ + html.head({ + html.meta(charset: "utf-8") + html.meta(name: "viewport", content: "width=device-width, initial-scale=1") + html.title(title) + html.link(rel: "stylesheet", href: static("style.css")) + }) + + html.header({ + html.nav[ + (nav element) // TODO when there's an index page + ] + }) + + html.main({ + html.article({ + html.header({ + html.h1(title) + html.p(id: "author")[by #author, published on #html.time(datetime: date)[#format-date-en(date)]] + }) + + body + }) + }) + + html.footer[ + footer goes here + ] + }) +}
\ No newline at end of file diff --git a/site/content/gsoc/otsu2018_1.typ b/site/content/gsoc/otsu2018_1.typ new file mode 100644 index 0000000..f86db9b --- /dev/null +++ b/site/content/gsoc/otsu2018_1.typ @@ -0,0 +1,58 @@ +#import "/common.typ": post, link-self + +#show: post.with( + title: "Otsu et al. (2018), part 1", + date: datetime(year: 2020, month: 7, day: 23), +) + +Otsu et al. (2018) @Otsu2018 is the second spectral upsampling method I'm adding to Colour. It's a data-driven method, which not only produces reflectances matching the given color exactly, but also that are natural and resemble real, measured reflectances. This is in contrast with, for example, Jakob and Hanika (2019) @Jakob2019, which I worked on in June and uses a mathematical model. + +At the moment the code is split in two parts. The #link("https://github.com/enneract/colour/tree/feature/otsu2018")[feature branch] in my fork implements the method using the dataset published with the article. Creating new datasets is done by #link("https://github.com/enneract/otsu2018")[this program] and will be covered in depth in a later article. Work is mostly done, I just need to integrate the program into Colour and complete this series of blog posts. + += Reconstruction + +Spectral reconstruction in this method is very simple and straightforward, thus also remarkably fast to compute. Let $bold(s)$ be the reconstructed reflectance vector: +$ + bold(s) = w_1 bold(b)_1 + w_2 bold(b)_2 + w_3 bold(b)_3 + bold(b)_mu +$ +where $bold(b)_i$ are three *basis vectors* and $bold(b)_mu$ is the dataset mean. The original code used spectra discretized at 36 points (at $Delta lambda = 10 "nm"$ intervals), making the vectors 36-dimensional. Since the four vectors are known (precomputed ahead of time), reconstruction is simply a matter of computing the weights $w_i$. First rewrite the equation in a matrix form, treating vectors as column vectors: +$ + bold(s) = + mat(bold(b)_1, bold(b)_2, bold(b)_3) + bold(w) + bold(b)_mu +$ +In a #link-self("gsoc/differentiating_code")[previous article] I've already shown how to compute CIE 1931 XYZ tristimulus values given a reflectance. Written in a matrix form, with $bold(c)$ being a vector of the tristimulus values: +$ + bold(c) = k Delta lambda bold(I) + mat(overline(bold(x))^T; overline(bold(y))^T; overline(bold(z))^T) + bold(s) +$ +The $Delta lambda$ term appears because of discretization. Combining the two equations gives: +$ + bold(c) &= k Delta lambda bold(I) + mat(overline(bold(x))^T; overline(bold(y))^T; overline(bold(z))^T) + (mat(bold(b)_1, bold(b)_2, bold(b)_3) + bold(w) + bold(b)_mu) \ + &= underbrace( + k Delta lambda bold(I) + mat(overline(bold(x))^T; overline(bold(y))^T; overline(bold(z))^T) + mat(bold(b)_1, bold(b)_2, bold(b)_3), + M, + ) + bold(w) + bold(c)_mu +$ +A quick note about the dimensions of $M$. The matrix of cmfs is three 36-dimensional row vectors stacked vertically, a 3x36 matrix, while the basis matrix is three column vectors stacked horizontally, a 36x3 matrix. Multiplying the two yields a 3x3 matrix, which can be inverted (degenerate cases aside). + +Solving this equation is very simple. Move $bold(c)_mu$ to the left side and left-multiply both sides by the inverse of $M$. +$ + M^(-1) (bold(c) - bold(c)_mu) = bold(w) +$ +Knowing the weights is enough to do the reconstruction. An interesting thing to note is that $M^(-1)$ and $bold(c)_mu$ could be pre-computed (for a given illuminant and cmfs), making the reconstruction process potentially extremely fast. Also, an RGB-to-XYZ matrix could be pre-multiplied into $M$ to speed up upsampling of RGB data. + += Notes + +This method works on discrete spectra. The range and resolution are fixed and determined by the pre-computed dataset. On the other hand, this method could theoretically also be used for emissive spectra, so it can be quite versatile. + +While reconstruction is very simple, coming up with good basis vectors is not. It's a complex and time consuming process that I'll cover in the next (at least) two parts. + +#bibliography("/refs.bib") diff --git a/site/content/gsoc/otsu2018_2.typ b/site/content/gsoc/otsu2018_2.typ new file mode 100644 index 0000000..4ae754b --- /dev/null +++ b/site/content/gsoc/otsu2018_2.typ @@ -0,0 +1,79 @@ +#import "/common.typ": post, link-self, static, img + +#show: post.with( + title: "Otsu et al. (2018), part 2", + date: datetime(year: 2020, month: 7, day: 24), +) + +The #link-self("gsoc/otsu2018_1")[previous post] mentions three basis vectors and a "dataset mean." Here I explain how to find a good basis and what "good" means in this context. + +The idea is to find a basis so that *any* spectral reflectivity can be expressed in the form: +$ + bold(s) = sum_i w_i bold(b)_i + bold(b)_mu +$ +where $i$ runs over all dimensions of the space (36 using Otsu et al.'s spectral data @Otsu2018) and $bold(b)_mu$ is a constant term you can ignore for now. This is a very common problem and there are many general solutions. By far the most common one is the discrete Fourier transform, which turns arbitrary signals into weighted sums of sines and cosines. + +Unfortunately, recalling that the goal is to upsample a color triplet (e.g. XYZ tristimulus values or RGB data), there are only three degrees of freedom to work with and so at most three weights can be determined. Reformulating the problem, +$ + bold(s) = w_1 bold(b)_1 + w_2 bold(b)_2 + w_3 bold(b)_3 + bold(epsilon) + bold(b)_mu +$ +where all but three terms are lumped together into an error term $bold(epsilon)$. I think it's clear that three sines or cosines (and a constant term) added together won't accurately reconstruct most spectra. There are far better purely mathematical models, such as Jakob and Hanika (2019) @Jakob2019. + += Principal component analysis + +Otsu et al. came up with a *data-driven* solution. Given enough measured data, you can use statistics and linear algebra to create a small basis that carries as much information from the original dataset as possible. The authors used *principal component analysis* (PCA) and a set of $N = 1,279$ measured reflectances. + +Spectral reflectances are treated as random vectors, with each component a random variable. This method requires that their sample means are zero, so the measured data has to be adjusted first. Let +$ + bold(b)_mu = 1 / N sum_i bold(s)_i +$ +which I called the "dataset mean" in the previous article, then simply subtract it from measured spectra: +$ + bold(s)'_i = bold(s) - bold(b)_mu +$ +From these the so-called *data matrix*, in this case an N x 36 matrix, can be constructed: +$ + X = mat(bold(s)'_1, bold(s)'_2, dots.h, bold(s)_N)^T +$ +Now the idea is to use this to derive a basis that maximizes its variance or "significance." I'll skip the proofs, as I'm not particularly well-versed in statistics and it would take me too much effort. The result is given by the eigensystem of the covariance matrix $Sigma$ of $X$. The eigenvector of $Sigma$ with the largest eigenvalue is the vector maximizing variance. The eigenvector with the second largest eigenvalue is perpendicular and has the second largest variance, and so on. + +Let's take a look at the code. Assume that the measured data, `sds`, is an N x 36 array. +``` +basis_mean = np.mean(sds, axis=0) +data_matrix = sds - basis_mean +``` +A covariance matrix can be easily computed with `np.cov`, but a trick can be used. Because the column-wise means are zero, $X^T X$ is going to be equal to it up to a constant. The constant and any normalization can be ignored, as only eigenvectors are sought after. +``` +covariance_matrix = np.dot(data_matrix.T, data_matrix) +``` +The last step is the eigendecomposition. NumPy comes with a few algorithms, so it's not a problem. Because `cov` is Hermitian (it's a real, symmetric matrix), one can use: +``` +eigenvalues, eigenvectors = np.linalg.eigh(covariance_matrix) +``` +The bonus feature of `eigh` (aside from being more efficient) is that the eigenvalues are sorted in ascending order. The result is simply the last three columns of `eigenvectors`: the three basis vectors with the greatest variance. + +Because zero-mean spectra were used to create the basis, it best describes primed spectra: +$ + bold(s)' = w_1 bold(b)_1 + w_2 bold(b)_2 + w_3 bold(b)_3 +$ +Adding $bold(b)_mu$ to both sides, +$ + bold(s) = w_1 bold(b)_1 + w_2 bold(b)_2 + w_3 bold(b)_3 + bold(b)_mu +$ +recovers the equation from the beginning of this post and explains why the fourth basis vector has kept appearing. Notice that the error term is missing: the reconstructed spectrum is slightly different. I'll discuss this in part 3. + +_Thanks to Sıddık Açil for #link("https://dev.to/akaame/implementing-simple-pca-using-numpy-3k0a")[his article] on performing PCA in NumPy and Hao Xie for letting me know about it._ + += Visualizing + +Below you can see plots of a few eigenvectors derived from Otsu et al.\'s dataset. Since the vectors are meaningful only up to a constant, the Y scale can be chosen arbitrarily (and differently for each vector). The usual way to visualize them is to make a "ladder" plot, with the vectors stacked vertically according to their number. Eigenvalues are written on the right. The script is available #link("https://github.com/enneract/otsu2018/blob/master/pca_decomposition.py")[here]. + +The results look #link("https://en.wikipedia.org/wiki/Quantum_harmonic_oscillator")[oddly familiar]. Notice that every next eigenvector (starting from the top) has exactly one more zero crossing than the previous one. My guess is that this has to be the case for them to be perpendicular and linearly independent. The eigenvalues are interesting too. They decrease very rapidly, with the largest few being much, much larger than the others. This explains why just three basis functions are enough for a good reconstruction. + +#img(static("gsoc/otsu2018_eigenstates_1.png")) + +As expected, the eigenvectors corresponding to small eigenvalues are noisy and random-looking. + +#img(static("gsoc/otsu2018_eigenstates_2.png")) + +#bibliography("/refs.bib") diff --git a/site/content/gsoc/otsu2018_3.typ b/site/content/gsoc/otsu2018_3.typ new file mode 100644 index 0000000..aa1666e --- /dev/null +++ b/site/content/gsoc/otsu2018_3.typ @@ -0,0 +1,79 @@ +#import "/common.typ": post, link-self, static, img + +#show: post.with( + title: "Otsu et al. (2018), part 3", + date: datetime(year: 2020, month: 7, day: 26), +) + +In the #link-self("gsoc/otsu2018_2")[previous post] I explained how PCA can be used to create a convenient basis for representing real-world reflectivities. The procedure uses a set of measured spectra to compute an orthonormal basis, sorted by variance or "statistical significance." All but the three most significant vectors are then truncated. I called the difference between the actual spectrum and the reconstructed error term $bold(epsilon)$. + +Naturally the smaller the error, the better. Because $bold(epsilon)$ is a vector, the authors chose the Euclidean norm (2-norm or L2) to represent the (scalar) error: +$ + epsilon = sqrt(sum_i bold(epsilon)_i^2) +$ +To get an idea of how well the method works in the general case, the authors would sum up reconstruction errors of *all* spectra in the dataset. I call this the total error $E$: +$ + E = sum_i epsilon |_(bold(s)_i) +$ +where $epsilon |_(bold(s)_i)$ denotes the reconstruction error of $bold(s)_i$, the $i$-th measured spectrum. + +Using just one basis for everything isn't ideal. The picture below shows reconstructions of a few colorchecker spectra. While by definition XYZ tristimulus values (and perceived colors) match perfectly, the spectra aren't that close. + +#figure( + img(static("gsoc/otsu_clustering1.png")), + caption: [Reconstructions of a few colorchecker spectra.], +) + += Clustering + +The authors @Otsu2018 decided to subdivide the CIE 1931 xy space using a binary space partitioning tree. Every node that isn't a leaf divides its subspace in two with a horizontal or vertical line. Leaves, called clusters in the article, contain spectra that lie in their subspace. PCA is done for every cluster separately, creating a set of bases. + +#figure( + img(static("gsoc/chromaticity_diagram.png")), + caption: [CIE 1931 xy chromaticity diagram], +) + +A quick note about CIE 1931 xy chromaticity coordinates. They're derived from CIE 1931 XYZ tristimulus values: +$ + x &= X / (X + Y + Z) \ + y &= Y / (X + Y + Z) +$ +They're often used where only the chromaticity matters and not the lightness $Y$. When plotted, they give rise to the familiar "color triangle" or "horseshoe" diagram, pictured on the right. + +The algorithm for creating an optimized clustering is described as follows. +- Search every possible new partitioning in the tree. For every color of every leaf, try partitions with a horizontal and a vertical line going through the color, assigning the color itself to the "lesser" part (to the left or below the line). +- Discard partitions that would create very small nodes. More on this later. +- Compute how each partition would affect the total error of the entire tree (the sum of $E$ of every leaf). Discard those that would increase it. +- Realize the partition that decreases the error the most. +- Repeat the entire process. +The algorithm terminates when no more partitions are possible or there are enough clusters. Authors chose 8 clusters, because of diminishing returns and dataset size concerns. You can look at #link("http://lightmetrica.org/h-otsu/download/rgb2spec.zip")[the original implementation] or #link("https://github.com/enneract/otsu2018/tree/d218ef0097174897634477bcc180b6268ba6c337")[my code]. The article also has many more plots and comparisons than I post here, so it's definitely worth looking at too. + +Below are the results of running my code. I used the entire dataset of 1,279 spectra and optimized for 8 clusters. This takes about 3 hours on my dated i7 3770. + +#figure( + img(static("gsoc/otsu_clustering2b.png")), + caption: [A visualization of the clustering. Each cluster has its own symbol for the colors that belong to it. (I actually used the cross symbol twice, my bad.)], +) + +#figure( + img(static("gsoc/otsu_clustering2a.png")), + caption: [Reconstructions of the same colorchecker spectra as before, this time using clustering. The discrepancies are notably smaller and some reconstructed spectra look exactly like the measurements.], +) + += Minimum node size + +I mentioned that nodes cannot be allowed to be split too finely. First of all, there have to be at least three spectra in a node for PCA to even work. Fewer will result in a singular $M$ and the algorithm simply won't be able to continue. In practice, having few spectra is no good either. Below is a result of PCA decomposition done with just three spectra. + +#figure( + img(static("gsoc/otsu2018_eigenstates_3_colors.png")), + caption: [PCA decomposition of the first three spectra in Otsu et al.\'s dataset. An explanation of what is plotted here is in the #link-self("gsoc/otsu2018_2")[previous post].], +) + +Only *two* eigenvectors are meaningful and the rest are basically noise. Trying to use this for reconstruction will create completely random-looking spectra, usually going way outside the [0, 1] range. Clearly a lot more data needs to be used to get useful results. In my program I (arbitrarily) set the minimum cluster size to the average cluster size divided by two and it seems to work pretty well. +$ + "min. cluster size" = floor(1 / 2 (("num. of spectra") / ("num. of clusters"))) +$ + +But can reconstructed spectra exceed the allowed [0, 1] range even if the basis is good? Unfortunately yes, and this will be the topic of the next (and the last) post in this series. + +#bibliography("/refs.bib") diff --git a/site/content/gsoc/otsu2018_4.typ b/site/content/gsoc/otsu2018_4.typ new file mode 100644 index 0000000..a5d01db --- /dev/null +++ b/site/content/gsoc/otsu2018_4.typ @@ -0,0 +1,17 @@ +#import "/common.typ": post, link-self, static, img + +#show: post.with( + title: "Otsu et al. (2018), part 4", + date: datetime(year: 2020, month: 8, day: 10), +) + +In the #link-self("gsoc/otsu2018_3")[previous part] I mentioned that reflectances reconstructed using this method can exceed the [0, 1] range and violate conservation of energy. Indeed, I could simply ask it for an imaginary color and get a weird, unphysical spectrum. + +What's interesting is that this doesn't happen in practice, when upsampling real data. Out of almost 1300 reflectances I tested, I found only one bad reconstruction. Using the original clustering, the result for `magenta` from `BabelColor Average` colorchecker reflects 100,6% of light at 730 nm. Clipping this value to 100% creates a tiny color difference, orders of magnitude smaller than the #link("https://en.wikipedia.org/wiki/Just-noticeable_difference")[just-noticeable difference]. + +#figure( + img(static("gsoc/otsu2018_error.png")), + caption: [You can see the small difference between the clipped and unclipped reflectances in the upper right corner of the figure. (Click to enlarge.)], +) + +Many methods have this property of being able to create perfect metamers, so looking at ΔE's isn't very interesting. In a future post I'll take a look at MSE (mean square error), which quantifies differences between spectra themselves, and compare a few methods. diff --git a/site/content/integrating_cos2x.typ b/site/content/integrating_cos2x.typ new file mode 100644 index 0000000..a7cfe4b --- /dev/null +++ b/site/content/integrating_cos2x.typ @@ -0,0 +1,67 @@ +#import "/common.typ": post, link-self + +#show: post.with( + title: "Integrating cos² x", + date: datetime(year: 2018, month: 5, day: 1), +) + +$ + integral cos^2 x dif x = (x + sin x cos x) / 2 + C +$ + += Using the Pythagorean identity + +Integrate by parts using $u = cos x, d u = -sin x$ and $v = sin x, d v = cos x$: + +$ + & integral cos x cos x dif x = sin x cos x - integral -sin x sin x dif x \ + & integral cos^2 x dif x = sin x cos x + integral sin^2 x dif x +$ + +Add $integral (cos x)^2 dif x$ to both sides: + +$ + 2 integral cos^2 x dif x = sin x cos x + integral sin^2 x dif x + integral cos^2 x dif x +$ + +Apply integral linearity: + +$ + 2 integral cos^2 x dif x = sin x cos x + integral (sin^2 x + cos^2 x) dif x +$ + +Apply the Pythagorean identity and divide both sides by 2: + +$ + 2 integral cos^2 x dif x &= sin x cos x + integral 1 dif x \ + & = sin x cos x + x + C \ + integral cos^2 x dif x &= (x + sin x cos x) / 2 + C +$ + += Using the double angle formula + +#html.elem("aside", attrs: (class: "warning"))[ + Careful! This derivation differs from the one in #link-self("integrating_sin2x")[integrating_sin2x] by just a sign. +] + +Rewrite the integral using the double angle formula for the cosine: + +$ + integral cos^2 x dif x = integral (1 + cos 2 x) / 2 dif x +$ + +Substitute $u = 2 x, d u = 2 dif x$: + +$ + integral (1 + cos 2 x) / 2 dif x &= integral (1 + cos u) / 2 1 / 2 dif u \ + &= 1 / 4 (integral 1 dif u + integral cos u dif u) \ + &= (u + sin u + C) / 4 +$ + +Substitute back and simplify: + +$ + (u + sin u + C) / 4 &= (2 x + sin 2 x + C) / 4 \ + &= (x + (sin 2 x) / 2) / 2 + C \ + &= (x + sin x cos x) / 2 + C +$ diff --git a/site/content/integrating_sin2x.typ b/site/content/integrating_sin2x.typ new file mode 100644 index 0000000..65b17a6 --- /dev/null +++ b/site/content/integrating_sin2x.typ @@ -0,0 +1,66 @@ +#import "/common.typ": post, link-self + +#show: post.with( + title: "Integrating sin² x", + date: datetime(year: 2018, month: 5, day: 1), +) + +$ + integral sin^2 x dif x = (x - sin x cos x) / 2 + C +$ + += Using the Pythagorean identity + +Integrate by parts using $u = sin x, d u = cos x$ and $v = -cos x, d v = sin x$: + +$ + & integral sin x sin x dif x = -sin x cos x - integral -cos x cos x dif x \ + & integral sin^2 x dif x = -sin x cos x + integral cos^2 x dif x +$ + +Add $integral (sin x)^2 dif x$ to both sides: + +$ + 2 integral sin^2 x dif x = -sin x cos x + integral cos^2 x dif x + integral sin^2 x dif x +$ + +Apply integral linearity: + +$ + 2 integral sin^2 x dif x = -sin x cos x + integral (cos^2 x + sin^2 x) dif x +$ + +Apply the Pythagorean identity and divide both sides by 2: + +$ + 2 integral sin^2 x dif x &= -sin x cos x + integral 1 dif x \ + &= -sin x cos x + x + C \ + integral sin^2 x dif x &= (x - sin x cos x) / 2 + C +$ + += Using the double angle formula + +#html.elem("aside", attrs: (class: "warning"))[ + Careful! This derivation differs from the one in #link-self("integrating_cos2x")[integrating_cos2x] by just a sign. +] +Rewrite the integral using the double angle formula for the cosine: + +$ + integral sin^2 x dif x = integral (1 - cos 2 x) / 2 dif x +$ + +Substitute $u = 2 x, d u = 2 dif x$: + +$ + integral (1 - cos 2 x) / 2 dif x &= integral (1 - cos u) / 2 1 / 2 dif u \ + &= 1 / 4 (integral 1 dif u - integral cos u dif u) \ + &= (u - sin u + C) / 4 +$ + +Substitute back and simplify: + +$ + (u - sin u + C) / 4 &= (2 x - sin 2 x + C) / 4 \ + &= (x - (sin 2 x) / 2) / 2 + C \ + &= (x - sin x cos x) / 2 + C +$ diff --git a/site/content/schachtmeister2.typ b/site/content/schachtmeister2.typ new file mode 100644 index 0000000..f000360 --- /dev/null +++ b/site/content/schachtmeister2.typ @@ -0,0 +1,66 @@ +#import "/common.typ": post + +#show: post.with( + title: "schachtmeister2", + date: datetime(year: 2020, month: 8, day: 15), +) + +`schachtmeister2` is a daemon for white- and blacklisting specified ISPs, hosting providers and so on. It was created for the Der Bunker Tremulous server to combat malicious players evading bans. + +The source code is available #link("https://redman.xyz/git/schachtmeister2")[here]. + +The daemon works by looking up a given IP address in WHOIS databases and reverse DNS records and analyzing the results. Its configuration file, `schachts.list`, is a list of keywords to be searched for along with a *partial rating*. The program's output, the *rating*, is the sum of partial ratings of all matched keywords. + += Usage + +Since the daemon is meant to be used with a Tremulous server, it communicates over UDP. The protocol is extremely simple. To query `schachtmeister2` send an UDP packet (port 1337) that looks like: + +``` +\xff\xff\xff\xffsm2query ADDRESS +``` + +Note the four leading `0xFF` bytes. The daemon will send a reply to the sender's address and port that will look like: + +``` +\xff\xff\xff\xffsm2reply ADDRESS RATING +``` + +Here's an example using BSD's netcat, asking for the rating of `8.8.8.8` (Google's DNS): + +``` +$ printf "\xff\xff\xff\xffsm2query 8.8.8.8" | nc -u 127.0.0.1 1337 +����sm2reply 8.8.8.8 0 +``` + += The list + +The daemon needs a list of keywords to operate meaningfully. The program looks for a file named `schachts.list` in its working directory. Below is an example list: + +``` +// a few known VPS and VPN providers +revdns -50 tcpvpn.com +revdns -50 vultr.com + +whois -50 "OVH" +whois -50 "DigitalOcean" +whois -50 "Digital Ocean" + +// Desala's ISP +revdns -10 .dynamic.chello.pl +``` + +The columns are: + +- Where to look for the substring: `revdns` or `whois`? +- The partial rating or how much the total rating changes if this substring is found. +- The substring to look for. Use quotation marks if you need spaces. The program does not support regular expressions yet. + +Whitespace doesn't matter but I recommend tabs for proper alignment. You can use C++-style one-line comments. + += Reloading on the fly + +Restarting the daemon clears its cache, so you might want to avoid it. To make `schachtmeister2` reload the list, send `SIGUSR1` to the process. + += Limitations + +This is one of those programs that I've written quickly, as crude prototypes, but have worked exceedingly well. As I couldn't be bothered to rewrite the program better, it's not very flexible and works well only in a single application. I might continue working on it if there's demand outside of Tremulous. diff --git a/site/content/typst_migration.typ b/site/content/typst_migration.typ new file mode 100644 index 0000000..50d28cd --- /dev/null +++ b/site/content/typst_migration.typ @@ -0,0 +1,12 @@ +#import "/common.typ": post + +#show: post.with( + title: "Migration to Typst", + date: datetime(year: 2026, month: 6, day: 14), +) + + +Loose thoughts: + +- Fully automatic DokuWiki -> Typst conversion using GPT 5.5 +- Finds typos
\ No newline at end of file diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..317d439 --- /dev/null +++ b/site/index.html @@ -0,0 +1,12 @@ +<html> + <body> + <h1>Blog</h1> + <ul> + {% for url, metadata in pages.items() %} + <li> + <a href="{{ url }}">{{ metadata.title }}</a> + </li> + {% endfor %} + </ul> + </body> +</html> diff --git a/site/refs.bib b/site/refs.bib new file mode 100644 index 0000000..8d79bfc --- /dev/null +++ b/site/refs.bib @@ -0,0 +1,31 @@ +@article{Otsu2018, + author = {Hisanari Otsu and Masafumi Yamamoto and Toshiya Hachisuka}, + title = {Reproducing Spectral Reflectances From Tristimulus Colours}, + journal = {Computer Graphics Forum}, + volume = {37}, + number = {6}, + pages = {370--381}, + year = {2018}, + doi = {10.1111/cgf.13332}, +} + +@article{Jakob2019, + author = {Wenzel Jakob and Johannes Hanika}, + title = {A Low-Dimensional Function Space for Efficient Spectral Upsampling}, + journal = {Computer Graphics Forum}, + volume = {38}, + number = {2}, + pages = {147--155}, + year = {2019}, + doi = {10.1111/cgf.13626}, +} + +@inproceedings{Mallett2019, + author = {Ian Mallett and Cem Yuksel}, + title = {Spectral Primary Decomposition for Rendering with {sRGB} Reflectance}, + booktitle = {Eurographics Symposium on Rendering - DL-Only and Industry Track}, + pages = {9--15}, + year = {2019}, + publisher = {The Eurographics Association}, + doi = {10.2312/SR.20191216}, +} diff --git a/static/gsoc/chromaticity_diagram.png b/static/gsoc/chromaticity_diagram.png Binary files differnew file mode 100644 index 0000000..255aab6 --- /dev/null +++ b/static/gsoc/chromaticity_diagram.png diff --git a/static/gsoc/otsu2018_eigenstates_1.png b/static/gsoc/otsu2018_eigenstates_1.png Binary files differnew file mode 100644 index 0000000..071daf5 --- /dev/null +++ b/static/gsoc/otsu2018_eigenstates_1.png diff --git a/static/gsoc/otsu2018_eigenstates_2.png b/static/gsoc/otsu2018_eigenstates_2.png Binary files differnew file mode 100644 index 0000000..d4755b2 --- /dev/null +++ b/static/gsoc/otsu2018_eigenstates_2.png diff --git a/static/gsoc/otsu2018_eigenstates_3_colors.png b/static/gsoc/otsu2018_eigenstates_3_colors.png Binary files differnew file mode 100644 index 0000000..19ed8eb --- /dev/null +++ b/static/gsoc/otsu2018_eigenstates_3_colors.png diff --git a/static/gsoc/otsu2018_error.png b/static/gsoc/otsu2018_error.png Binary files differnew file mode 100644 index 0000000..7924779 --- /dev/null +++ b/static/gsoc/otsu2018_error.png diff --git a/static/gsoc/otsu_clustering1.png b/static/gsoc/otsu_clustering1.png Binary files differnew file mode 100644 index 0000000..ea97d02 --- /dev/null +++ b/static/gsoc/otsu_clustering1.png diff --git a/static/gsoc/otsu_clustering2a.png b/static/gsoc/otsu_clustering2a.png Binary files differnew file mode 100644 index 0000000..bb1f30b --- /dev/null +++ b/static/gsoc/otsu_clustering2a.png diff --git a/static/gsoc/otsu_clustering2b.png b/static/gsoc/otsu_clustering2b.png Binary files differnew file mode 100644 index 0000000..87f05d6 --- /dev/null +++ b/static/gsoc/otsu_clustering2b.png diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..5db9fcc --- /dev/null +++ b/static/style.css @@ -0,0 +1,19 @@ +body { + max-width: 72ch; + margin: 0 auto; + padding: 2rem 1rem; + line-height: 1.6; +} + +h1 { + margin-bottom: 0; +} + +#author { + margin-top: 0;; + text-align: right; +} + +img { + max-width: 100%; +} @@ -0,0 +1,85 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "redman-xyz-2026" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "jinja2" }, + { name = "typst" }, +] + +[package.metadata] +requires-dist = [ + { name = "jinja2" }, + { name = "typst" }, +] + +[[package]] +name = "typst" +version = "0.14.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/77/b4856939057e0007d5399b9d425545a729708714dc0de4e5028c79c94539/typst-0.14.9.tar.gz", hash = "sha256:53a7ee0825c127c100023685eeb8fb911226d7ad9ae861a370c11bb0f5c160cc", size = 63372, upload-time = "2026-05-14T00:54:24.094Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/dc/340783f8514d776314408201a48b187ce8f7fc63896b3da895faf5e7c200/typst-0.14.9-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:abc1348393390173ba7b993389b06b3df4b635cf69205aedaa05ce51de9be3b6", size = 23124939, upload-time = "2026-05-14T00:53:36.098Z" }, + { url = "https://files.pythonhosted.org/packages/66/81/9860be782781c43956e71cc9dbdb5f79207de56b4400d8266bf91a993e07/typst-0.14.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e3d62e29ce38c45e94e81716383c153272438964b76c343b9a2183fe52a9ff02", size = 22789372, upload-time = "2026-05-14T00:53:39.204Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/3ab10001e57d38a20d3cd74e9060e1103a069265c881f5b4dab3a906f9e2/typst-0.14.9-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f054414502fd21b84474bdd1c92263be1910e010a5a6bdb0d546d7a00131f91", size = 27127014, upload-time = "2026-05-14T00:53:42.104Z" }, + { url = "https://files.pythonhosted.org/packages/60/b1/26e98492703ff20b4b5cd2515f6f3d5881a85c76414f557cdd5088043f14/typst-0.14.9-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2676e5b6548ae83b13ff3b769f5f30cf5d716b712502ea64a8e98fc3ae4577f", size = 27073390, upload-time = "2026-05-14T00:53:45.352Z" }, + { url = "https://files.pythonhosted.org/packages/96/3e/4af0a0f84030f19438d54e2d1b8f37a8c5e1cad83b9f58ee40ac83d05699/typst-0.14.9-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:456ecf0e9bf02748fb3d7b6f0176aff36027ee8c2e81056eca20b3d4ba54e82b", size = 28447355, upload-time = "2026-05-14T00:53:48.449Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/be73406f081fc6a2ae3e96e7dc7c3c983e43b3f92db16f753dd90b3cc65f/typst-0.14.9-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a67963bd4e3b6969eee412a2bf49753276cd95be0acc86c07fd0c06f188a41bc", size = 27233190, upload-time = "2026-05-14T00:53:51.745Z" }, + { url = "https://files.pythonhosted.org/packages/f8/23/44249884d2aebdc01d66c9c5ecdd99f79547615ab32247e4f243c2ab6d94/typst-0.14.9-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabac1449c75381828996eaf352bfd795857668834a4c9cc6308d7687352ed49", size = 26206171, upload-time = "2026-05-14T00:53:54.577Z" }, + { url = "https://files.pythonhosted.org/packages/81/8e/be9ab684001336f1bae61022204521041151036f5d8a5365c34a29ec727c/typst-0.14.9-cp314-cp314t-win_amd64.whl", hash = "sha256:b5637861645b0abb2529e1006a8984a831ecd9d24610a60e99d3ae0af4e4691a", size = 21647758, upload-time = "2026-05-14T00:53:57.538Z" }, + { url = "https://files.pythonhosted.org/packages/88/20/10ab263e9d52f6ca717e682ea4ac301204cb41be3bf08974d9513d52033f/typst-0.14.9-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4c4404891b3c8bfbb01d9e9308a24b9637088fb41aea0f24b58a9dffdd43b164", size = 23134109, upload-time = "2026-05-14T00:54:00.099Z" }, + { url = "https://files.pythonhosted.org/packages/84/24/15f5e9ba7ecfea955a0311410e51a8c7990f9167e4fe0f98c7755e4c9580/typst-0.14.9-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9ade0c99e853bf02bcc28f1bad8be3714de5ee5323e1a0f649abdb4e634240e9", size = 22797078, upload-time = "2026-05-14T00:54:03.058Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1f/faf63f411c6e3fee8b7540ce0e666fea06fd338e81115880ad0889dd0de4/typst-0.14.9-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1339a1d8beb05f098090a8a6c627e1aa7d4c7792b333e59694d30072f6f0ccdb", size = 27144371, upload-time = "2026-05-14T00:54:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ca/6b948b617de32fba14c036f141ee5c2ec8f3c282bbc7001663203c312afe/typst-0.14.9-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:792818bb147ea4a88b0555fd2066e1bb0b65353aecd01270a8acd3be018f3e39", size = 27082638, upload-time = "2026-05-14T00:54:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/ee/da/0045f3ce468a5b17551b2de2a5dc17338e8ab39e8d9c9171ee4ccf20cc0a/typst-0.14.9-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4ecc5102125880baebbe454946a8492f6377680bf3d74f60650fae093958e3b", size = 28470089, upload-time = "2026-05-14T00:54:11.116Z" }, + { url = "https://files.pythonhosted.org/packages/4c/49/6e43f443bcf137a27d0622e6a12147ad4255cefd20f1e59d2b895b5c3192/typst-0.14.9-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7be2e851ee3ff3561c79d244a2809089fb7cfc678912555ecabb031d5de74da9", size = 27250762, upload-time = "2026-05-14T00:54:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/3ac1d2001495ce81d9f26a25287f728664eb3bf6845c3a9bae7ea22a050e/typst-0.14.9-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be26d1f195b75d4c57e7411646124f8257d136417cc5e9c393dcd8ea3c3a11e8", size = 26219098, upload-time = "2026-05-14T00:54:16.052Z" }, + { url = "https://files.pythonhosted.org/packages/84/ea/e0cc7e8eb1872db3aa68f297b940e55a8b16487e09a518cfc069cf749453/typst-0.14.9-cp38-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:2f7008b41217194a41b9eec611cef358cc7c8d90193817b182f9abed62526984", size = 18398038, upload-time = "2026-05-14T00:54:18.786Z" }, + { url = "https://files.pythonhosted.org/packages/50/d1/33d71cc89dc66e5a703a980ccee3217c8486a156a4e57fe7897b4097ce90/typst-0.14.9-cp38-abi3-win_amd64.whl", hash = "sha256:6ebe6b433f71e9ec03c444b26f123363ab63ebab9a289fc4f26d6962b18fad4e", size = 21651664, upload-time = "2026-05-14T00:54:21.757Z" }, +] |
