diff --git a/packages/preview/typshade/0.1.3/LICENSE b/packages/preview/typshade/0.1.3/LICENSE
new file mode 100644
index 0000000000..a66a55f980
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/LICENSE
@@ -0,0 +1,338 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ , 1 April 1989
+ Moe Ghoul, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
\ No newline at end of file
diff --git a/packages/preview/typshade/0.1.3/README.md b/packages/preview/typshade/0.1.3/README.md
new file mode 100644
index 0000000000..964f3ddc08
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/README.md
@@ -0,0 +1,206 @@
+# Typshade
+
+
+
+Typshade is a Typst package for visualizing multiple sequence alignments in bioinformatics.
+
+It provides a Typst-native interface centered on `shade(...)`, offering a readable and composable way to render alignments, add annotations, and incorporate logos, structure tracks, and graph tracks.
+
+Inspired by [TeXshade](https://ctan.org/pkg/texshade), Typshade rethinks alignment visualization with a focus on clarity, composability, and a Typst-native user experience.
+
+## Why Typshade?
+
+TeXshade is powerful, but its UI reflects TeX: many global commands, implicit state, and order-sensitive setup before the alignment is rendered. Typshade keeps the feature set while making the source read like a figure specification.
+
+TeXshade style:
+
+```latex
+\begin{texshade}{alignment.msf}
+ \shadingmode[similar]{identical}
+ \shadingcolors{blues}
+ \residuesperline{45}
+ \setends{1}{80..125}
+ \showruler{top}{1}
+ \rulersteps{10}
+ \showconsensus{bottom}
+ \showsequencelogo{top}
+ \shaderegion{1}{NPA}{White}{BrickRed}
+ \feature{top}{1}{NXX[ST]N}{box[Yellow]}{motif}
+\end{texshade}
+```
+
+Typshade style:
+
+```typst
+#let alignment = read("alignment.msf", encoding: none)
+
+#shade(
+ alignment,
+ format: "msf",
+ figure: publication(
+ similarity: "blues",
+ region: "80..125",
+ logo: "charge",
+ motifs: (
+ "NPA": (bg: "BrickRed", text: "active site"),
+ "NXX[ST]N": "motif",
+ ),
+ ),
+)
+```
+
+The lower-level helpers are still available through `commands:` when you need precise control, but new documents can usually start from the kind of figure you want: publication figure, motif map, structure map, or logo analysis.
+
+## Quick Start
+
+```typst
+#import "@preview/typshade:0.1.3": *
+
+#let alignment = read("alignment.msf", encoding: none)
+
+#shade(
+ alignment,
+ format: "msf",
+ theme: "screen",
+ figure: motif-map(auto),
+)
+```
+
+`read(..., encoding: none)` remains supported on Typst 0.15 and later. On Typst 0.15 or later, you can additionally pass a resolved project path and let Typshade read the source inside the package:
+
+```typst
+#shade(path("alignment.msf"), format: "msf", figure: motif-map(auto))
+```
+
+## Preview
+
+Protein alignment with similarity shading, motif annotations, a ruler,
+a conservation track, and a legend:
+
+
+
+Protein alignment with hydropathy-based functional coloring:
+
+
+
+Nucleotide alignment with DNA coloring, a sequence logo, a conservation track,
+and a ruler:
+
+
+
+## Typst-Native Helpers
+
+- `shade(...)`: Named-option alignment renderer for new documents.
+- `figure:`: Purpose-level recipe slot for complete figure designs.
+- `publication`, `motif-map`, `structure-map`, `logo-analysis`, `overview`: High-level recipes.
+- `similar`, `identical`, `diverse`, `functional`, `lines`, `window`, `ruler`, `consensus`, `logo`, `legend`: Compact command helpers; `lines(auto)` and `fit: "container"` fit the current Typst container.
+- `auto-layout(...)`, `auto-page(...)`, and `fit: "page"`: Typst-aware line length and page-aware block splitting for long figures.
+- `color-scheme`, `scoring-mode`, `sequence-window`: Small, readable option helpers.
+- `ruler-track`, `consensus-track`, `sequence-logo`, `subfamily-logo`, `legend-track`: Track helpers.
+- `structure-tracks(...)`: Adds topology/secondary-structure tracks from sidecar files.
+- `shade-preset("publication" | "overview" | "logo" | "functional" | "structure")`: Reusable command bundles.
+- `shade-theme("classic" | "print" | "screen" | "warm" | "nature")` and `visual-theme(...)`: Color/style bundles.
+- `highlight`, `tint`, `emphasize`, `mark`, `motif`, `graph`: Readable command builders for common annotations; graph metrics include conservation, entropy, gap-fraction, coverage, identity, hydrophobicity, molecular weight, and charge.
+- `select-range`, `select-motif`, `select-metric`, `select-and`, `select-or`, `select-not`, and `select-pad`: Composable Selection DSL values for windows, highlights, marks, graphs, and analysis helpers.
+- `cell-style(ctx => ...)`: Data-driven per-cell styling with Typst functions.
+- `pdb-point`, `pdb-line`, `pdb-plane`: Safer constructors for PDB selections.
+- `alignment-position("left" | "center" | "right")`: Overrides the default left-aligned block placement.
+- `alignment-summary(...)`, `alignment-debug(...)`, `cell-inspect(...)`, and `selection-preview(...)`: In-document inspection helpers.
+- `sequence-list(...)` and `selection-table(...)`: Typst tables for data-aware reports.
+- `percent-identity(...)`, `percent-similarity(...)`, and `similarity-table(...)`: Pairwise identity/similarity analysis.
+- `alignment-data(...)` and `parse-alignment(...)`: Data access helpers for custom Typst logic.
+
+## TeXshade To Typshade
+
+| TeXshade idea | Typshade API |
+|---|---|
+| `texshade` environment | `shade(read("alignment.msf", encoding: none), format: "msf", figure: publication(...))`, or `shade(path("alignment.msf"), format: "msf", ...)` on Typst 0.15+ |
+| `shadingmode`, `shadingcolors`, `threshold` | `similar`, `identical`, `diverse`, `functional`, or `scoring-mode`, `color-scheme`, `threshold` |
+| `residuesperline`, `setends` | `lines`, `window`, `fit`, `auto-layout`, `auto-page`, or Selection DSL values with `sequence-window` |
+| `shownames`, `shownumbering`, `showconsensus`, `showruler` | `names`, `numbers`, `consensus`, `ruler`, or the fine-grained track helpers |
+| `showsequencelogo`, `showsubfamilylogo`, `showlegend` | `logo`, `subfamily-logo`, `legend` |
+| `shaderegion`, `tintregion`, `emphregion`, `feature` | `highlight`, `tint`, `emphasize`, `mark`, `motif`, `graph` |
+| `includeDSSP`, `includeSTRIDE`, `includeHMMTOP`, `includePHD*` | `structure-tracks`, `dssp-track`, `stride-track`, `hmmtop-track`, `phd-topology-track`, `phd-secondary-track` |
+| font and spacing macros | `text-family`, `text-weight`, `text-posture`, `text-size`, `block-gap`, `feature-slot-space` |
+
+See the [full documentation](https://github.com/rice8y/typshade/blob/v0.1.3/docs/documentation.pdf) for the full guide and a larger correspondence table.
+
+## Smart Recipes
+
+Recipes inspect the alignment before rendering. For example, `motif-map(auto)` detects common motifs for the sequence type, focuses the region around them, chooses a readable line length, adds conservation when useful, and enables a logo only when the figure stays readable. Override any option when you need exact control.
+
+## Fine-Grained Control
+
+Macro-style command names are intentionally not part of the public API. Use Typst-shaped command helpers when you need detailed control:
+
+- Scoring: `threshold`, `weight-table`, `set-weight`, `gap-penalty`, `residue-style`, `functional-group`.
+- Tracks: `names-track`, `numbering-track`, `consensus-name`, `consensus-symbols`, `ruler-name`, `ruler-marker`, `logo-color`.
+- Sequence layout: `start-number`, `sequence-length`, `domain`, `hide-sequence`, `sequence-order`, `separation-line`.
+- Features and structure: `feature-rule`, `feature-text-label`, `backtranslation-label`, `show-structure-types`, `structure-appearance`, `stride-track`, `dssp-track`, `hmmtop-track`, `phd-topology-track`, `phd-secondary-track`.
+- Typography and spacing: `text-family`, `text-weight`, `text-posture`, `text-size`, `character-stretch`, `line-stretch`, `block-gap`, `feature-slot-space`.
+
+## Custom Control
+
+Use recipes when you know the purpose of the figure:
+
+```typst
+#let alignment = read("alignment.msf", encoding: none)
+
+#shade(
+ alignment,
+ format: "msf",
+ figure: publication(
+ region: "80..125",
+ logo: "charge",
+ motifs: (
+ "NPA": (bg: "BrickRed", text: "active site"),
+ "NXX[ST]N": "glycosylation",
+ ),
+ ),
+)
+```
+
+Use `commands:` when you want to assemble visible parts yourself:
+
+```typst
+#let alignment = read("alignment.msf", encoding: none)
+
+#shade(
+ alignment,
+ format: "msf",
+ preset: "publication",
+ theme: "screen",
+ commands: (
+ similar(colors: "blues", threshold: 45),
+ lines(45),
+ window(1, "80..125"),
+ ruler("top", sequence: 1, every: 10),
+ consensus("bottom", name: "conservation"),
+ logo("top", colors: "charge"),
+ legend(),
+ highlight(1, "NPA", bg: "BrickRed"),
+ motif(1, "NXX[ST]N", text: "motif"),
+ graph("bottom", 1, "all", "conservation", kind: "color", options: ("ColdHot",)),
+ ),
+)
+```
+
+You can also mix recipe output with explicit, reproducible helper lists:
+
+```typst
+#let alignment = read("alignment.msf", encoding: none)
+
+#shade(
+ alignment,
+ format: "msf",
+ figure: publication(region: "80..125"),
+ commands: (
+ highlight(1, "NXX[DE][KR]XXQ", fg: "White", bg: "BrickRed"),
+ sequence-logo(position: "top"),
+ ),
+)
+```
+
+# License
+
+This project is distributed under the GPL v2 License. See [LICENSE](LICENSE) for details.
diff --git a/packages/preview/typshade/0.1.3/images/readme-overview.png b/packages/preview/typshade/0.1.3/images/readme-overview.png
new file mode 100644
index 0000000000..d6061b9d0e
Binary files /dev/null and b/packages/preview/typshade/0.1.3/images/readme-overview.png differ
diff --git a/packages/preview/typshade/0.1.3/images/readme-preview-1.png b/packages/preview/typshade/0.1.3/images/readme-preview-1.png
new file mode 100644
index 0000000000..254c23ae0f
Binary files /dev/null and b/packages/preview/typshade/0.1.3/images/readme-preview-1.png differ
diff --git a/packages/preview/typshade/0.1.3/images/readme-preview-2.png b/packages/preview/typshade/0.1.3/images/readme-preview-2.png
new file mode 100644
index 0000000000..f1811b13e1
Binary files /dev/null and b/packages/preview/typshade/0.1.3/images/readme-preview-2.png differ
diff --git a/packages/preview/typshade/0.1.3/images/readme-preview-3.png b/packages/preview/typshade/0.1.3/images/readme-preview-3.png
new file mode 100644
index 0000000000..ece3ee65a6
Binary files /dev/null and b/packages/preview/typshade/0.1.3/images/readme-preview-3.png differ
diff --git a/packages/preview/typshade/0.1.3/internal/engine/commands.typ b/packages/preview/typshade/0.1.3/internal/engine/commands.typ
new file mode 100644
index 0000000000..8032f56ee2
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/engine/commands.typ
@@ -0,0 +1,24 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#let _add-command(out, value) = {
+ if value == none {
+ return out
+ }
+ if type(value) == array {
+ for item in value {
+ out = _add-command(out, item)
+ }
+ } else {
+ out.push(value)
+ }
+ out
+}
+
+#let command-pack(..items) = {
+ let out = ()
+ for item in items.pos() {
+ out = _add-command(out, item)
+ }
+ out
+}
diff --git a/packages/preview/typshade/0.1.3/internal/engine/config.typ b/packages/preview/typshade/0.1.3/internal/engine/config.typ
new file mode 100644
index 0000000000..314a1e6903
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/engine/config.typ
@@ -0,0 +1,1045 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../model/parser.typ": _chars, _lower, _upper, read-dssp, read-hmmtop, read-phd-secondary, read-phd-topology, read-stride, read-tcoffee
+#import "../model/palette.typ": _dna-groups, _dna-sims, _functional-presets, _pep-groups, _pep-sims, resolve-color
+#import "../model/pdb.typ": pdb-selection-list
+#import "../model/text-style.typ": _default-text-styles, _set-text-style
+
+#let _default-style(kind) = if kind == "identical" {
+ (
+ nomatch: (fg: "Black", bg: "White", case: "upper"),
+ similar: (fg: "Black", bg: "Magenta", case: "upper"),
+ conserved: (fg: "White", bg: "RoyalBlue", case: "upper"),
+ allmatch: (fg: "Goldenrod", bg: "RoyalPurple", case: "upper"),
+ )
+} else { (
+ nomatch: (fg: "Black", bg: "White", case: "upper"),
+ similar: (fg: "Black", bg: "Magenta", case: "upper"),
+ conserved: (fg: "White", bg: "RoyalBlue", case: "upper"),
+ allmatch: (fg: "Goldenrod", bg: "RoyalPurple", case: "upper"),
+) }
+
+#let _apply-shading-colors(config, name) = {
+ let style = config.at("residue-style")
+ if name == "blues" {
+ style.insert("similar", (fg: "Black", bg: "Magenta", case: "upper"))
+ style.insert("conserved", (fg: "White", bg: "RoyalBlue", case: "upper"))
+ style.insert("allmatch", (fg: "Goldenrod", bg: "RoyalPurple", case: "upper"))
+ } else if name == "greens" {
+ style.insert("similar", (fg: "Black", bg: "GreenYellow", case: "upper"))
+ style.insert("conserved", (fg: "White", bg: "PineGreen", case: "upper"))
+ style.insert("allmatch", (fg: "YellowOrange", bg: "OliveGreen", case: "upper"))
+ } else if name == "reds" {
+ style.insert("similar", (fg: "Black", bg: "YellowOrange", case: "upper"))
+ style.insert("conserved", (fg: "White", bg: "BrickRed", case: "upper"))
+ style.insert("allmatch", (fg: "YellowGreen", bg: "Mahagony", case: "upper"))
+ } else if name == "grays" {
+ style.insert("similar", (fg: "Black", bg: "LightGray", case: "upper"))
+ style.insert("conserved", (fg: "White", bg: "DarkGray", case: "upper"))
+ style.insert("allmatch", (fg: "White", bg: "Black", case: "upper"))
+ } else if name == "black" {
+ style.insert("similar", (fg: "Black", bg: "White", case: "upper"))
+ style.insert("conserved", (fg: "White", bg: "Black", case: "upper"))
+ style.insert("allmatch", (fg: "White", bg: "Black", case: "upper"))
+ } else if config.at("shading-schemes").keys().contains(name) {
+ let saved = config.at("shading-schemes").at(name)
+ for key in saved.at("residue-style").keys() {
+ style.insert(key, saved.at("residue-style").at(key))
+ }
+ for key in saved.at("gaps").keys() {
+ config.at("gaps").insert(key, saved.at("gaps").at(key))
+ }
+ }
+}
+
+#let _style-record(fg, bg, case, style) = (fg: fg, bg: bg, case: case, style: style)
+#let _func-style-record(name, residues, fg, bg, case, style) = (name: name, residues: _upper(str(residues)), fg: fg, bg: bg, case: case, style: style)
+
+#let _residue-masses = (
+ A: 89.1, C: 121.2, D: 133.1, E: 147.1, F: 165.2, G: 75.1, H: 155.2, I: 131.2,
+ K: 146.2, L: 131.2, M: 149.2, N: 132.1, P: 115.1, Q: 146.1, R: 174.2, S: 105.1,
+ T: 119.1, V: 117.1, W: 204.2, Y: 181.2,
+)
+
+#let _peptide-charges = (
+ C: -0.03, D: -1.0, E: -1.0, H: 0.165, K: 1.0, R: 1.0,
+)
+
+#let _standard-genetic-code = (
+ A: "GCN", C: "TGY", D: "GAY", E: "GAR", F: "TTY", G: "GGN", H: "CAY", I: "ATH",
+ K: "AAR", L: "YTN", M: "ATG", N: "AAY", P: "CCN", Q: "CAR", R: "MGN", S: "WSN",
+ T: "ACN", V: "GTN", W: "TGG", Y: "TAY", ".": "TRR",
+)
+
+#let _clean-residue-string(sequence) = _upper(str(sequence)).replace(".", "").replace("-", "").replace(" ", "").replace("\n", "")
+
+#let molweight(sequence, unit: "Da") = {
+ let total = 0.0
+ for residue in _chars(_clean-residue-string(sequence)) {
+ total += _residue-masses.at(residue, default: 0.0)
+ }
+ if unit == "kDa" or unit == "kda" {
+ str(calc.round(total / 10.0) / 100.0)
+ } else {
+ str(calc.round(total))
+ }
+}
+
+#let charge(sequence, termini: "o") = {
+ let total = 0.0
+ for residue in _chars(_clean-residue-string(sequence)) {
+ total += _peptide-charges.at(residue, default: 0.0)
+ }
+ let mode = _lower(str(termini))
+ if mode == "o" or mode == "n" {
+ total += 0.91
+ }
+ if mode == "o" or mode == "c" {
+ total -= 1.0
+ }
+ str(calc.round(total * 100.0) / 100.0)
+}
+
+#let _style-snapshot(config) = {
+ let styles = (:)
+ for key in config.at("residue-style").keys() {
+ let value = config.at("residue-style").at(key)
+ styles.insert(key, (
+ fg: value.at("fg"),
+ bg: value.at("bg"),
+ case: value.at("case", default: "upper"),
+ style: value.at("style", default: "normal"),
+ ))
+ }
+ let gaps = (:)
+ for key in config.at("gaps").keys() {
+ gaps.insert(key, config.at("gaps").at(key))
+ }
+ (residue-style: styles, gaps: gaps)
+}
+
+#let _group-list(value) = {
+ if type(value) == str {
+ return value.split(",").map(group => _upper(group.trim())).filter(group => group != "")
+ }
+ value.map(group => _upper(str(group).trim())).filter(group => group != "")
+}
+
+#let _ref-list(value) = {
+ if type(value) == str {
+ let out = ()
+ for item in value.split(",") {
+ let trimmed = item.trim()
+ if trimmed == "" {
+ continue
+ }
+ let range-hit = trimmed.matches(regex("^(\\d+)\\-(\\d+)$"))
+ if range-hit.len() > 0 {
+ let start = int(range-hit.first().captures.at(0))
+ let stop = int(range-hit.first().captures.at(1))
+ let step = if start <= stop { 1 } else { -1 }
+ let current = start
+ while current != stop + step {
+ out.push(current)
+ current += step
+ }
+ } else {
+ out.push(trimmed)
+ }
+ }
+ return out
+ }
+ if type(value) == array {
+ return value
+ }
+ (value,)
+}
+
+#let _type-list(value) = if type(value) == str { value.split(",").map(v => v.trim()).filter(v => v != "") } else { value }
+
+#let _hide-structure-types(config, filetype, types) = {
+ let remove = _type-list(types)
+ let kept = ()
+ for item in config.at("structure-show").at(filetype, default: ()) {
+ if not remove.contains(item) {
+ kept.push(item)
+ }
+ }
+ config.at("structure-show").insert(filetype, kept)
+}
+
+#let _default-config() = {
+ let config = (
+ threshold: 50,
+ allmatch-threshold: 100,
+ weight-table: "identity",
+ custom-weights: (:),
+ gap-penalty: 0,
+ residues-per-line: 60,
+ auto-layout: (
+ fit: none,
+ min: 1,
+ max: none,
+ ),
+ auto-page: (
+ enabled: false,
+ blocks: none,
+ repeat-legend: false,
+ ),
+ font: "DejaVu Sans Mono",
+ font-families: (
+ serif: "Times New Roman",
+ sans: "Arial",
+ ),
+ font-size: 9pt,
+ line-gap: 2pt,
+ block-gap: 8pt,
+ fixed-block-space: false,
+ char-stretch: 1.0,
+ numbering-width-digits: 4,
+ text-styles: _default-text-styles,
+ alignment: "left",
+ align-right-labels: false,
+ seq-type: none,
+ show-leading-gaps: true,
+ single-seq-shift: none,
+ keep-single-seq-gaps: false,
+ hide-residues: false,
+ hide-allmatch-positions: false,
+ hide-seqs: false,
+ no-shade: (),
+ sequence-names: (:),
+ hidden-names: (),
+ hidden-numbers: (),
+ sequence-name-colors: (:),
+ sequence-number-colors: (:),
+ names-color: "Black",
+ numbering-color: "Black",
+ start-numbers: (:),
+ sequence-lengths: (:),
+ allow-zero: false,
+ stop-char: "*",
+ names: ("show": true, "position": "left"),
+ numbering: ("show": true, "left": false, "right": true),
+ consensus: (
+ "show": true,
+ "position": "bottom",
+ "scale": none,
+ "name": "consensus",
+ "source": "all",
+ "symbols": ("none": "", "conserved": "*", "allmatch": "!"),
+ "colors": (
+ "none": (fg: "Black", bg: "White"),
+ "conserved": (fg: "Black", bg: "White"),
+ "allmatch": (fg: "Black", bg: "White"),
+ ),
+ ),
+ ruler: ("show": false, "position": "top", "sequence": 1, "steps": 10, "color": "Black"),
+ ruler-colors: ("top": "Black", "bottom": "Black"),
+ ruler-names: ("top": "", "bottom": ""),
+ ruler-name-colors: ("top": "Black", "bottom": "Black"),
+ ruler-labels: ("top": (:), "bottom": (:)),
+ ruler-spacing: ("top": 0pt, "bottom": 0pt),
+ ruler-rotation: ("top": false, "bottom": false),
+ gaps: ("char": ".", "fg": "Black", "bg": "White", "rule-thickness": 0.3pt),
+ shading: ("mode": "identical", "option": none, "scheme": "blues", "reference": none),
+ shading-schemes: (:),
+ tcoffee: ("source": none, "scores": (:)),
+ pep-groups: _pep-groups,
+ dna-groups: _dna-groups,
+ pep-sims: _pep-sims,
+ dna-sims: _dna-sims,
+ functional-groups: _functional-presets,
+ functional-style-overrides: (:),
+ functional-default: "charge",
+ residue-style: _default-style("identical"),
+ cell-styles: (),
+ sequence-logo: ("show": false, "position": "top", "colorset": none, "name": "logo", "scale": "leftright", "stretch": 1),
+ subfamily-logo: ("show": false, "position": "top", "colorset": none, "name": "subfamily", "negative-name": "remaining", "show-negatives": true),
+ logo-scale: ("show": true, "position": "leftright", "color": "Black"),
+ logo-colors: ("default": none, "map": (:)),
+ relevance: ("show": true, "color": "Black", "char": "*", "threshold": 0.1),
+ frequency-correction: false,
+ legend: ("show": false, "color": "Black", "dx": 0pt, "dy": 0pt),
+ captions: ("top": none, "bottom": none, "short": none),
+ separation-lines: (),
+ separation-space: 6pt,
+ bar-graph-stretch: ("default": 1.0),
+ color-scale-stretch: ("default": 1.0),
+ feature-style-names: (:),
+ feature-text-names: (:),
+ feature-style-name-colors: ("default": "Black"),
+ feature-text-name-colors: ("default": "Black"),
+ feature-slot-spacing: (
+ ttttop: 0pt,
+ tttop: 0pt,
+ ttop: 0pt,
+ top: 0pt,
+ bottom: 0pt,
+ bbottom: 0pt,
+ bbbottom: 0pt,
+ bbbbottom: 0pt,
+ ),
+ subfamily: (),
+ structure-show: (
+ "DSSP": ("alpha", "3-10", "pi", "beta"),
+ "STRIDE": ("alpha", "3-10", "pi", "beta"),
+ "PHDsec": ("alpha", "beta"),
+ "PHDtopo": ("internal", "external", "TM"),
+ "HMMTOP": ("TM"),
+ ),
+ structure-appearance: (
+ "PHDtopo:internal": ("position": "bottom", "style": "-", "text": "int."),
+ "PHDtopo:external": ("position": "top", "style": ",-,", "text": "ext."),
+ "PHDtopo:TM": ("position": "top", "style": "box", "text": "TM"),
+ "HMMTOP:internal": ("position": "bottom", "style": "-", "text": "int."),
+ "HMMTOP:external": ("position": "top", "style": ",-,", "text": "ext."),
+ "HMMTOP:TM": ("position": "top", "style": "helix", "text": "TM"),
+ "PHDsec:alpha": ("position": "top", "style": "box", "text": "α\\numcount"),
+ "PHDsec:beta": ("position": "top", "style": "-->", "text": "β\\numcount"),
+ "STRIDE:alpha": ("position": "top", "style": "box:$\\alpha$\\numcount", "text": ""),
+ "STRIDE:3-10": ("position": "top", "style": "fill:$\\circ$", "text": "3-10"),
+ "STRIDE:pi": ("position": "top", "style": "---", "text": "π"),
+ "STRIDE:beta": ("position": "top", "style": "-->", "text": "β\\numcount"),
+ "STRIDE:bridge": ("position": "top", "style": "fill:$\\uparrow$", "text": ""),
+ "STRIDE:turn": ("position": "top", "style": ",-,", "text": "turn"),
+ "DSSP:alpha": ("position": "top", "style": "box:$\\alpha$\\numcount", "text": ""),
+ "DSSP:3-10": ("position": "top", "style": "fill:$\\circ$", "text": "3-10"),
+ "DSSP:pi": ("position": "top", "style": "---", "text": "π"),
+ "DSSP:beta": ("position": "top", "style": "-->", "text": "β\\numcount"),
+ "DSSP:bridge": ("position": "top", "style": "fill:$\\uparrow$", "text": ""),
+ "DSSP:turn": ("position": "top", "style": ",-,", "text": "turn"),
+ "DSSP:bend": ("position": "top", "style": "fill:$\\diamond$", "text": ""),
+ ),
+ dssp-second-column: true,
+ structure-data: (),
+ sequence-windows: (),
+ emph-default: "italic",
+ tint-default: "normal",
+ shade-regions: (),
+ tint-regions: (),
+ lower-regions: (),
+ emph-regions: (),
+ frame-regions: (),
+ hidden: (),
+ killed: (),
+ order: none,
+ features: (),
+ feature-rule: 0.5pt,
+ genetic-code: _standard-genetic-code,
+ backtranslation: (
+ label-style: "alternating",
+ label-size: "tiny",
+ text-style: "horizontal",
+ text-size: "tiny",
+ ),
+ exported-consensus: none,
+ )
+ _apply-shading-colors(config, "blues")
+ config
+}
+
+#let _command(kind, fields) = {
+ let out = fields
+ out.insert("kind", kind)
+ out
+}
+
+#let sequence-type(value) = _command("sequence-type", (value: value))
+#let scoring-mode(mode, option: none) = _command("scoring-mode", (mode: mode, option: option))
+#let color-scheme(name) = _command("color-scheme", (name: name))
+#let define-color-scheme(name) = _command("define-color-scheme", (name: name))
+#let threshold(value) = _command("threshold", (value: value))
+#let shade-all-residues() = _command("shade-all-residues", (:))
+#let all-match-threshold(value: 100) = _command("all-match-threshold", (value: value))
+#let disable-all-match-threshold() = _command("disable-all-match-threshold", (:))
+#let hide-all-match-positions() = _command("hide-all-match-positions", (:))
+#let show-all-match-positions() = _command("show-all-match-positions", (:))
+#let weight-table(name) = _command("weight-table", (name: name))
+#let set-weight(residue-a, residue-b, value) = _command("set-weight", (residue-a: residue-a, residue-b: residue-b, value: value))
+#let gap-penalty(value) = _command("gap-penalty", (value: value))
+#let residues-per-line(value) = _command("residues-per-line", (value: value))
+#let auto-layout(fit: "container", min: 1, max: none) = _command("auto-layout", (fit: fit, min: min, max: max))
+#let auto-page(blocks: auto, repeat-legend: true) = _command("auto-page", (blocks: blocks, repeat-legend: repeat-legend))
+#let numbering-track(position: "right", color: none) = _command("numbering-track", (position: position, color: color))
+#let no-numbering-track() = _command("no-numbering-track", (:))
+#let names-track(position: "left", color: none) = _command("names-track", (position: position, color: color))
+#let no-names-track() = _command("no-names-track", (:))
+#let sequence-name(sequence, name) = _command("sequence-name", (sequence: sequence, name: name))
+#let names-color(color) = _command("names-color", (color: color))
+#let sequence-name-color(sequences, color) = _command("sequence-name-color", (sequences: sequences, color: color))
+#let hide-sequence-name(sequences) = _command("hide-sequence-name", (sequences: sequences))
+#let numbering-color(color) = _command("numbering-color", (color: color))
+#let sequence-number-color(sequences, color) = _command("sequence-number-color", (sequences: sequences, color: color))
+#let hide-sequence-number(sequences) = _command("hide-sequence-number", (sequences: sequences))
+#let consensus-track(position: "bottom", scale: none, name: none) = _command("consensus-track", (position: position, scale: scale, name: name))
+#let no-consensus-track() = _command("no-consensus-track", (:))
+#let consensus-name(name) = _command("consensus-name", (name: name))
+#let language(name) = _command("language", (name: name))
+#let consensus-symbols(none-symbol, conserved-symbol, allmatch-symbol) = _command("consensus-symbols", ("none": none-symbol, conserved: conserved-symbol, allmatch: allmatch-symbol))
+#let consensus-colors(none-fg: "Black", none-bg: "White", conserved-fg: "Black", conserved-bg: "White", allmatch-fg: "Black", allmatch-bg: "White") = _command("consensus-colors", (none-fg: none-fg, none-bg: none-bg, conserved-fg: conserved-fg, conserved-bg: conserved-bg, allmatch-fg: allmatch-fg, allmatch-bg: allmatch-bg))
+#let residue-style(target, fg, bg, case: "upper", style: "normal") = _command("residue-style", (target: target, fg: fg, bg: bg, case: case, style: style))
+#let cell-style(callback) = _command("cell-style", (callback: callback))
+#let clear-functional-groups() = _command("clear-functional-groups", (:))
+#let functional-group(name, residues, fg, bg, case: "upper", style: "normal") = _command("functional-group", (name: name, residues: residues, fg: fg, bg: bg, case: case, style: style))
+#let functional-style(residue, fg, bg, case: "upper", style: "normal") = _command("functional-style", (residue: residue, fg: fg, bg: bg, case: case, style: style))
+#let ruler-track(position: "top", sequence: 1, steps: none, color: none) = _command("ruler-track", (position: position, sequence: sequence, steps: steps, color: color))
+#let no-ruler-track(position: none) = _command("no-ruler-track", (position: position))
+#let ruler-steps(value, position: none) = _command("ruler-steps", (value: value, position: position))
+#let ruler-color(color, position: none) = _command("ruler-color", (color: color, position: position))
+#let ruler-name(name, position: none) = _command("ruler-name", (name: name, position: position))
+#let ruler-name-color(color, position: none) = _command("ruler-name-color", (color: color, position: position))
+#let ruler-marker(number, text, position: none, color: none) = _command("ruler-marker", (number: number, text: text, position: position, color: color))
+#let ruler-space(value, position: none) = _command("ruler-space", (value: value, position: position))
+#let rotate-ruler(..args) = {
+ let positional = args.pos()
+ let position = if positional.len() > 0 { positional.first() } else { args.named().at("position", default: none) }
+ _command("rotate-ruler", (position: position))
+}
+#let unrotate-ruler(..args) = {
+ let positional = args.pos()
+ let position = if positional.len() > 0 { positional.first() } else { args.named().at("position", default: none) }
+ _command("unrotate-ruler", (position: position))
+}
+#let gap-char(symbol) = _command("gap-char", (symbol: symbol))
+#let gap-rule(thickness) = _command("gap-rule", (thickness: thickness))
+#let gap-colors(foreground, background) = _command("gap-colors", (foreground: foreground, background: background))
+#let stop-char(symbol) = _command("stop-char", (symbol: symbol))
+#let peptide-groups(groups) = _command("peptide-groups", (groups: groups))
+#let dna-groups(groups) = _command("dna-groups", (groups: groups))
+#let peptide-similarities(residue, similars) = _command("peptide-similarities", (residue: residue, similars: similars))
+#let dna-similarities(residue, similars) = _command("dna-similarities", (residue: residue, similars: similars))
+#let start-number(sequence, start, selection: none) = _command("start-number", (sequence: sequence, start: start, selection: selection))
+#let allow-zero-numbering() = _command("allow-zero-numbering", (:))
+#let disallow-zero-numbering() = _command("disallow-zero-numbering", (:))
+#let sequence-length(sequence, length) = _command("sequence-length", (sequence: sequence, length: length))
+#let sequence-window(sequence, selection, start: none) = _command("sequence-window", (sequence: sequence, selection: selection, start: start))
+#let domain(sequence, selection) = sequence-window(sequence, selection)
+#let domain-gap-rule(thickness) = gap-rule(thickness)
+#let domain-gap-colors(foreground, background) = gap-colors(foreground, background)
+#let region-highlight(sequence, selection, ..args) = {
+ let positional = args.pos()
+ let named = args.named()
+ let bg = if positional.len() >= 2 { positional.at(1) } else { named.at("bg", default: none) }
+ let fg-or-scheme = if positional.len() >= 1 { positional.at(0) } else { named.at("fg-or-scheme", default: none) }
+ let scheme = named.at("scheme", default: none)
+ let resolved-scheme = if scheme != none { scheme } else if bg == none { fg-or-scheme } else { none }
+ let fg = if bg == none { named.at("fg", default: none) } else { fg-or-scheme }
+ _command("region-highlight", (sequence: sequence, selection: selection, scheme: resolved-scheme, fg: fg, bg: bg, all: named.at("apply-to-all", default: false)))
+}
+#let highlight-block(sequence, selection, ..args) = {
+ let command = region-highlight(sequence, selection, ..args)
+ command.insert("all", args.named().at("apply-to-all", default: true))
+ command
+}
+#let region-color-scheme(sequence, selection, scheme) = region-highlight(sequence, selection, scheme: scheme, apply-to-all: true)
+#let region-tint(sequence, selection, intensity: "medium") = _command("region-tint", (sequence: sequence, selection: selection, intensity: intensity))
+#let tint-block(sequence, selection, intensity: "medium") = region-tint(sequence, selection, intensity: intensity)
+#let tint-default(effect) = _command("tint-default", (effect: effect))
+#let region-lower(sequence, selection) = _command("region-lower", (sequence: sequence, selection: selection))
+#let lower-block(sequence, selection) = region-lower(sequence, selection)
+#let region-emphasis(sequence, selection, style: "italic") = _command("region-emphasis", (sequence: sequence, selection: selection, style: style))
+#let emphasis-block(sequence, selection, style: "italic") = region-emphasis(sequence, selection, style: style)
+#let emphasis-default(style) = _command("emphasis-default", (style: style))
+#let frame-block(sequence, selection, color: "Red") = _command("frame-block", (sequence: sequence, selection: selection, color: color))
+#let hide-sequence(sequence) = _command("hide-sequence", (sequence: sequence))
+#let hide-all-sequences() = _command("hide-all-sequences", (:))
+#let show-all-sequences() = _command("show-all-sequences", (:))
+#let remove-sequence(sequence) = _command("remove-sequence", (sequence: sequence))
+#let no-shade(sequences) = _command("no-shade", (sequences: sequences))
+#let separation-line(sequence) = _command("separation-line", (sequence: sequence))
+#let sequence-order(order) = _command("sequence-order", (order: order))
+#let feature(position, sequence, selection, style: "", text: "") = _command("feature", (position: position, sequence: sequence, selection: selection, style: style, text: text))
+#let feature-rule(thickness) = _command("feature-rule", (thickness: thickness))
+#let codon(residue, triplets) = _command("codon", (residue: residue, triplets: triplets))
+#let genetic-code(name) = _command("genetic-code", (name: name))
+#let backtranslation-label(..args) = {
+ let positional = args.pos()
+ let style = if positional.len() > 0 { positional.last() } else { args.named().at("style", default: "alternating") }
+ let size = args.named().at("size", default: "tiny")
+ _command("backtranslation-label", (style: style, size: size))
+}
+#let backtranslation-text(..args) = {
+ let positional = args.pos()
+ let style = if positional.len() > 0 { positional.last() } else { args.named().at("style", default: "horizontal") }
+ let size = args.named().at("size", default: "tiny")
+ _command("backtranslation-text", (style: style, size: size))
+}
+#let feature-text-label(position, name) = _command("feature-text-label", (position: position, name: name))
+#let feature-style-label(position, name) = _command("feature-style-label", (position: position, name: name))
+#let hide-feature-text-label(position) = _command("hide-feature-text-label", (position: position))
+#let hide-feature-style-label(position) = _command("hide-feature-style-label", (position: position))
+#let hide-feature-text-labels() = _command("hide-feature-text-labels", (:))
+#let hide-feature-style-labels() = _command("hide-feature-style-labels", (:))
+#let feature-text-label-color(color) = _command("feature-text-label-color", (color: color))
+#let feature-style-label-color(color) = _command("feature-style-label-color", (color: color))
+#let feature-text-label-color-at(position, color) = _command("feature-text-label-color-at", (position: position, color: color))
+#let feature-style-label-color-at(position, color) = _command("feature-style-label-color-at", (position: position, color: color))
+#let tcoffee-scores(source) = _command("tcoffee-scores", (source: source))
+#let sequence-logo-track(position: "top", colorset: none) = _command("sequence-logo-track", (position: position, colorset: colorset))
+#let no-sequence-logo-track() = _command("no-sequence-logo-track", (:))
+#let frequency-correction() = _command("frequency-correction", (:))
+#let no-frequency-correction() = _command("no-frequency-correction", (:))
+#let subfamily(sequences) = _command("subfamily", (sequences: sequences))
+#let subfamily-logo-track(position: "top", colorset: none) = _command("subfamily-logo-track", (position: position, colorset: colorset))
+#let no-subfamily-logo-track() = _command("no-subfamily-logo-track", (:))
+#let sequence-logo-name(name) = _command("sequence-logo-name", (name: name))
+#let subfamily-logo-name(name, negative-name: none) = _command("subfamily-logo-name", (name: name, negative-name: negative-name))
+#let logo-scale(position: "leftright", color: "Black") = _command("logo-scale", (position: position, color: color))
+#let no-logo-scale() = _command("no-logo-scale", (:))
+#let logo-stretch(value) = _command("logo-stretch", (value: value))
+#let negative-logo-values() = _command("negative-logo-values", (:))
+#let no-negative-logo-values() = _command("no-negative-logo-values", (:))
+#let relevance-threshold(value) = _command("relevance-threshold", (value: value))
+#let relevance-marker(char: "*", color: "Black") = _command("relevance-marker", (char: char, color: color))
+#let no-relevance-marker() = _command("no-relevance-marker", (:))
+#let logo-color(residues, color) = _command("logo-color", (residues: residues, color: color))
+#let clear-logo-colors(default: "Black") = _command("clear-logo-colors", (default: default))
+#let legend-track(color: "Black") = _command("legend-track", (color: color))
+#let no-legend-track() = _command("no-legend-track", (:))
+#let legend-color(color) = _command("legend-color", (color: color))
+#let legend-offset(dx, dy) = _command("legend-offset", (dx: dx, dy: dy))
+#let color-swatch(color) = box(width: 10pt, height: 10pt, fill: resolve-color(color), stroke: none)[]
+#let show-structure-types(format, types) = _command("show-structure-types", (format: format, types: types))
+#let hide-structure-types(format, types) = _command("hide-structure-types", (format: format, types: types))
+#let structure-appearance(format, structure-type, position, style, text) = _command("structure-appearance", (format: format, structure-type: structure-type, position: position, style: style, text: text))
+#let use-first-dssp-column() = _command("use-first-dssp-column", (:))
+#let use-second-dssp-column() = _command("use-second-dssp-column", (:))
+#let stride-track(sequence, source) = _command("stride-track", (sequence: sequence, source: source))
+#let dssp-track(sequence, source) = _command("dssp-track", (sequence: sequence, source: source))
+#let hmmtop-track(sequence, source, source-sequence: none) = _command("hmmtop-track", (sequence: sequence, source: source, source-sequence: source-sequence))
+#let phd-topology-track(sequence, source) = _command("phd-topology-track", (sequence: sequence, source: source))
+#let phd-secondary-track(sequence, source) = _command("phd-secondary-track", (sequence: sequence, source: source))
+#let consensus-from-sequence(sequence) = _command("consensus-from-sequence", (sequence: sequence))
+#let consensus-from-all-sequences() = _command("consensus-from-all-sequences", (:))
+#let show-leading-gaps() = _command("show-leading-gaps", (:))
+#let hide-leading-gaps() = _command("hide-leading-gaps", (:))
+#let shift-single-sequence(..args) = {
+ let positional = args.pos()
+ let value = if positional.len() > 0 { positional.first() } else { args.named().at("value", default: -1) }
+ _command("shift-single-sequence", (value: value))
+}
+#let keep-single-sequence-gaps() = _command("keep-single-sequence-gaps", (:))
+#let hide-residues() = _command("hide-residues", (:))
+#let show-residues() = _command("show-residues", (:))
+#let bar-graph-stretch(value, position: none) = _command("bar-graph-stretch", (value: value, position: position))
+#let color-scale-stretch(value, position: none) = _command("color-scale-stretch", (value: value, position: position))
+#let alignment(position) = _command("alignment", (position: position))
+#let character-stretch(value) = _command("character-stretch", (value: value))
+#let line-stretch(value) = _command("line-stretch", (value: value))
+#let numbering-width(digits) = _command("numbering-width", (digits: digits))
+#let fingerprint(value) = _command("fingerprint", (value: value))
+#let align-right-labels() = _command("align-right-labels", (:))
+#let align-left-labels() = _command("align-left-labels", (:))
+#let text-family(target, family) = _command("text-family", (target: target, value: family))
+#let text-weight(target, weight) = _command("text-weight", (target: target, value: weight))
+#let text-posture(target, posture) = _command("text-posture", (target: target, value: posture))
+#let text-size(target, size) = _command("text-size", (target: target, value: size))
+#let text-style(target, family, weight, posture, size) = _command("text-style", (target: target, family: family, weight: weight, posture: posture, size: size))
+#let caption(text, position: "bottom") = _command("caption", (text: text, position: position))
+#let short-caption(text) = _command("short-caption", (text: text))
+#let small-separator() = _command("separator-space", (value: 3pt))
+#let medium-separator() = _command("separator-space", (value: 6pt))
+#let large-separator() = _command("separator-space", (value: 12pt))
+#let export-consensus(sequence, filename, format: "chimera") = _command("export-consensus", (sequence: sequence, filename: filename, format: format))
+#let pdb-selection(selection) = pdb-selection-list(selection)
+#let no-block-gap() = _command("block-gap", (value: 0pt))
+#let small-block-gap() = _command("block-gap-factor", (value: 1.0))
+#let medium-block-gap() = _command("block-gap-factor", (value: 1.5))
+#let large-block-gap() = _command("block-gap-factor", (value: 2.0))
+#let block-gap(value) = _command("block-gap", (value: value))
+#let flexible-block-gap() = _command("block-space-mode", (fixed: false))
+#let fixed-block-gap() = _command("block-space-mode", (fixed: true))
+#let no-line-gap() = _command("line-gap", (value: 0pt))
+#let small-line-gap() = _command("line-gap", (value: 3pt))
+#let medium-line-gap() = _command("line-gap", (value: 6pt))
+#let large-line-gap() = _command("line-gap", (value: 12pt))
+#let line-gap(value) = _command("line-gap", (value: value))
+#let feature-slot-space(position, value) = _command("feature-slot-space", (position: position, value: value))
+
+#let _apply-command(config, command) = {
+ let kind = command.at("kind")
+ if kind == "sequence-type" {
+ config.insert("seq-type", _upper(str(command.at("value"))))
+ } else if kind == "scoring-mode" {
+ config.at("shading").insert("mode", command.at("mode"))
+ config.at("shading").insert("option", command.at("option"))
+ if command.at("mode") == "diverse" or command.at("mode") == "singleseq" {
+ let reference = command.at("option", default: none)
+ config.at("shading").insert("reference", if reference == none { 1 } else { reference })
+ } else if command.at("mode") == "T-Coffee" and command.at("option") != none {
+ config.at("tcoffee").insert("source", command.at("option"))
+ config.at("tcoffee").insert("scores", read-tcoffee(command.at("option")))
+ }
+ } else if kind == "color-scheme" {
+ config.at("shading").insert("scheme", command.at("name"))
+ _apply-shading-colors(config, command.at("name"))
+ } else if kind == "define-color-scheme" {
+ config.at("shading-schemes").insert(command.at("name"), _style-snapshot(config))
+ } else if kind == "tcoffee-scores" {
+ config.at("tcoffee").insert("source", command.at("source"))
+ config.at("tcoffee").insert("scores", read-tcoffee(command.at("source")))
+ } else if kind == "sequence-logo-track" {
+ config.at("sequence-logo").insert("show", true)
+ config.at("sequence-logo").insert("position", command.at("position"))
+ config.at("sequence-logo").insert("colorset", command.at("colorset"))
+ } else if kind == "no-sequence-logo-track" {
+ config.at("sequence-logo").insert("show", false)
+ } else if kind == "frequency-correction" {
+ config.insert("frequency-correction", true)
+ } else if kind == "no-frequency-correction" {
+ config.insert("frequency-correction", false)
+ } else if kind == "subfamily" {
+ config.insert("subfamily", command.at("sequences"))
+ } else if kind == "subfamily-logo-track" {
+ config.at("subfamily-logo").insert("show", true)
+ config.at("subfamily-logo").insert("position", command.at("position"))
+ config.at("subfamily-logo").insert("colorset", command.at("colorset"))
+ } else if kind == "no-subfamily-logo-track" {
+ config.at("subfamily-logo").insert("show", false)
+ } else if kind == "sequence-logo-name" {
+ config.at("sequence-logo").insert("name", command.at("name"))
+ } else if kind == "subfamily-logo-name" {
+ config.at("subfamily-logo").insert("name", command.at("name"))
+ if command.at("negative-name") != none {
+ config.at("subfamily-logo").insert("negative-name", command.at("negative-name"))
+ }
+ } else if kind == "logo-scale" {
+ config.at("logo-scale").insert("show", true)
+ config.at("logo-scale").insert("position", command.at("position"))
+ config.at("logo-scale").insert("color", command.at("color"))
+ } else if kind == "no-logo-scale" {
+ config.at("logo-scale").insert("show", false)
+ } else if kind == "logo-stretch" {
+ config.at("sequence-logo").insert("stretch", command.at("value"))
+ } else if kind == "negative-logo-values" {
+ config.at("subfamily-logo").insert("show-negatives", true)
+ } else if kind == "no-negative-logo-values" {
+ config.at("subfamily-logo").insert("show-negatives", false)
+ } else if kind == "relevance-threshold" {
+ config.at("relevance").insert("threshold", command.at("value"))
+ } else if kind == "relevance-marker" {
+ config.at("relevance").insert("show", true)
+ config.at("relevance").insert("char", command.at("char"))
+ config.at("relevance").insert("color", command.at("color"))
+ } else if kind == "no-relevance-marker" {
+ config.at("relevance").insert("show", false)
+ } else if kind == "logo-color" {
+ for residue in _chars(command.at("residues")) {
+ config.at("logo-colors").at("map").insert(_upper(residue), command.at("color"))
+ }
+ } else if kind == "clear-logo-colors" {
+ config.at("logo-colors").insert("default", command.at("default"))
+ config.at("logo-colors").insert("map", (:))
+ } else if kind == "legend-track" {
+ config.at("legend").insert("show", true)
+ config.at("legend").insert("color", command.at("color"))
+ } else if kind == "no-legend-track" {
+ config.at("legend").insert("show", false)
+ } else if kind == "legend-color" {
+ config.at("legend").insert("color", command.at("color"))
+ } else if kind == "legend-offset" {
+ config.at("legend").insert("dx", command.at("dx"))
+ config.at("legend").insert("dy", command.at("dy"))
+ } else if kind == "show-structure-types" {
+ config.at("structure-show").insert(command.at("format"), _type-list(command.at("types")))
+ } else if kind == "hide-structure-types" {
+ _hide-structure-types(config, command.at("format"), command.at("types"))
+ } else if kind == "structure-appearance" {
+ config.at("structure-appearance").insert(command.at("format") + ":" + command.at("structure-type"), ("position": command.at("position"), "style": command.at("style"), "text": command.at("text")))
+ } else if kind == "use-first-dssp-column" {
+ config.insert("dssp-second-column", false)
+ } else if kind == "use-second-dssp-column" {
+ config.insert("dssp-second-column", true)
+ } else if kind == "stride-track" {
+ config.at("structure-data").push(("kind": "STRIDE", "sequence": command.at("sequence"), "data": read-stride(command.at("source"))))
+ } else if kind == "dssp-track" {
+ config.at("structure-data").push(("kind": "DSSP", "sequence": command.at("sequence"), "data": read-dssp(command.at("source"), use-second-column: config.at("dssp-second-column"))))
+ } else if kind == "hmmtop-track" {
+ config.at("structure-data").push(("kind": "HMMTOP", "sequence": command.at("sequence"), "data": read-hmmtop(command.at("source"), sequence: command.at("source-sequence", default: none))))
+ } else if kind == "phd-topology-track" {
+ config.at("structure-data").push(("kind": "PHDtopo", "sequence": command.at("sequence"), "data": read-phd-topology(command.at("source"))))
+ } else if kind == "phd-secondary-track" {
+ config.at("structure-data").push(("kind": "PHDsec", "sequence": command.at("sequence"), "data": read-phd-secondary(command.at("source"))))
+ } else if kind == "threshold" {
+ config.insert("threshold", command.at("value"))
+ } else if kind == "shade-all-residues" {
+ config.insert("threshold", 0)
+ } else if kind == "all-match-threshold" {
+ config.insert("allmatch-threshold", command.at("value"))
+ } else if kind == "disable-all-match-threshold" {
+ config.insert("allmatch-threshold", 101)
+ } else if kind == "hide-all-match-positions" {
+ config.insert("hide-allmatch-positions", true)
+ } else if kind == "show-all-match-positions" {
+ config.insert("hide-allmatch-positions", false)
+ } else if kind == "weight-table" {
+ let name = str(command.at("name"))
+ config.insert("weight-table", name)
+ if name == "PAM250" {
+ config.insert("gap-penalty", -8)
+ } else if name == "PAM100" {
+ config.insert("gap-penalty", -9)
+ } else {
+ config.insert("gap-penalty", 0)
+ }
+ } else if kind == "set-weight" {
+ let a = _upper(str(command.at("residue-a")))
+ let b = _upper(str(command.at("residue-b")))
+ config.at("custom-weights").insert(a + ":" + b, command.at("value"))
+ config.at("custom-weights").insert(b + ":" + a, command.at("value"))
+ } else if kind == "gap-penalty" {
+ config.insert("gap-penalty", command.at("value"))
+ } else if kind == "residues-per-line" {
+ config.insert("residues-per-line", command.at("value"))
+ } else if kind == "auto-layout" {
+ config.at("auto-layout").insert("fit", command.at("fit"))
+ config.at("auto-layout").insert("min", command.at("min"))
+ config.at("auto-layout").insert("max", command.at("max"))
+ if command.at("fit") != none and command.at("fit") != false {
+ config.insert("residues-per-line", auto)
+ }
+ } else if kind == "auto-page" {
+ config.at("auto-page").insert("enabled", true)
+ config.at("auto-page").insert("blocks", command.at("blocks"))
+ config.at("auto-page").insert("repeat-legend", command.at("repeat-legend"))
+ } else if kind == "numbering-track" {
+ let pos = command.at("position")
+ config.at("numbering").insert("show", true)
+ config.at("numbering").insert("left", pos == "left" or pos == "leftright")
+ config.at("numbering").insert("right", pos == "right" or pos == "leftright")
+ if command.at("color") != none {
+ config.insert("numbering-color", command.at("color"))
+ }
+ } else if kind == "no-numbering-track" {
+ config.at("numbering").insert("show", false)
+ } else if kind == "names-track" {
+ config.at("names").insert("show", true)
+ config.at("names").insert("position", command.at("position"))
+ if command.at("color") != none {
+ config.insert("names-color", command.at("color"))
+ }
+ } else if kind == "no-names-track" {
+ config.at("names").insert("show", false)
+ } else if kind == "sequence-name" {
+ config.at("sequence-names").insert(str(command.at("sequence")), command.at("name"))
+ } else if kind == "names-color" {
+ config.insert("names-color", command.at("color"))
+ } else if kind == "sequence-name-color" {
+ for ref in _ref-list(command.at("sequences")) {
+ config.at("sequence-name-colors").insert(str(ref), command.at("color"))
+ }
+ } else if kind == "hide-sequence-name" {
+ for ref in _ref-list(command.at("sequences")) {
+ config.at("hidden-names").push(ref)
+ }
+ } else if kind == "numbering-color" {
+ config.insert("numbering-color", command.at("color"))
+ } else if kind == "sequence-number-color" {
+ for ref in _ref-list(command.at("sequences")) {
+ config.at("sequence-number-colors").insert(str(ref), command.at("color"))
+ }
+ } else if kind == "hide-sequence-number" {
+ for ref in _ref-list(command.at("sequences")) {
+ config.at("hidden-numbers").push(ref)
+ }
+ } else if kind == "consensus-track" {
+ config.at("consensus").insert("show", true)
+ config.at("consensus").insert("position", command.at("position"))
+ config.at("consensus").insert("scale", command.at("scale"))
+ if command.at("name") != none {
+ config.at("consensus").insert("name", command.at("name"))
+ }
+ } else if kind == "no-consensus-track" {
+ config.at("consensus").insert("show", false)
+ } else if kind == "consensus-name" {
+ config.at("consensus").insert("name", command.at("name"))
+ } else if kind == "language" {
+ let name = command.at("name")
+ config.at("consensus").insert("name", if name == "german" { "Konsensus" } else if name == "spanish" { "consenso" } else { "consensus" })
+ } else if kind == "consensus-symbols" {
+ config.at("consensus").insert("symbols", ("none": command.at("none"), "conserved": command.at("conserved"), "allmatch": command.at("allmatch")))
+ } else if kind == "consensus-colors" {
+ config.at("consensus").insert("colors", (
+ "none": (fg: command.at("none-fg"), bg: command.at("none-bg")),
+ "conserved": (fg: command.at("conserved-fg"), bg: command.at("conserved-bg")),
+ "allmatch": (fg: command.at("allmatch-fg"), bg: command.at("allmatch-bg")),
+ ))
+ } else if kind == "ruler-track" {
+ config.at("ruler").insert("show", true)
+ config.at("ruler").insert("position", command.at("position"))
+ config.at("ruler").insert("sequence", command.at("sequence"))
+ if command.at("steps") != none {
+ config.at("ruler").insert("steps", command.at("steps"))
+ }
+ if command.at("color") != none {
+ config.at("ruler").insert("color", command.at("color"))
+ config.at("ruler-colors").insert("top", command.at("color"))
+ config.at("ruler-colors").insert("bottom", command.at("color"))
+ }
+ } else if kind == "no-ruler-track" {
+ let pos = command.at("position")
+ if pos == none or pos == config.at("ruler").at("position") {
+ config.at("ruler").insert("show", false)
+ }
+ } else if kind == "ruler-steps" {
+ let pos = command.at("position")
+ if pos == none or pos == config.at("ruler").at("position") {
+ config.at("ruler").insert("steps", command.at("value"))
+ }
+ } else if kind == "ruler-color" {
+ let pos = command.at("position")
+ if pos == none {
+ config.at("ruler").insert("color", command.at("color"))
+ config.at("ruler-colors").insert("top", command.at("color"))
+ config.at("ruler-colors").insert("bottom", command.at("color"))
+ } else {
+ config.at("ruler-colors").insert(pos, command.at("color"))
+ }
+ } else if kind == "ruler-name" {
+ let pos = command.at("position")
+ if pos == none {
+ config.at("ruler-names").insert("top", command.at("name"))
+ config.at("ruler-names").insert("bottom", command.at("name"))
+ } else {
+ config.at("ruler-names").insert(pos, command.at("name"))
+ }
+ } else if kind == "ruler-name-color" {
+ let pos = command.at("position")
+ if pos == none {
+ config.at("ruler-name-colors").insert("top", command.at("color"))
+ config.at("ruler-name-colors").insert("bottom", command.at("color"))
+ } else {
+ config.at("ruler-name-colors").insert(pos, command.at("color"))
+ }
+ } else if kind == "ruler-marker" {
+ let pos = command.at("position")
+ let entry = (text: command.at("text"), color: command.at("color"))
+ if pos == none {
+ config.at("ruler-labels").at("top").insert(str(command.at("number")), entry)
+ config.at("ruler-labels").at("bottom").insert(str(command.at("number")), entry)
+ } else {
+ config.at("ruler-labels").at(pos).insert(str(command.at("number")), entry)
+ }
+ } else if kind == "ruler-space" {
+ let pos = command.at("position")
+ if pos == none {
+ config.at("ruler-spacing").insert("top", command.at("value"))
+ config.at("ruler-spacing").insert("bottom", command.at("value"))
+ } else {
+ config.at("ruler-spacing").insert(pos, command.at("value"))
+ }
+ } else if kind == "rotate-ruler" {
+ let pos = command.at("position")
+ if pos == none or pos == "n" {
+ config.at("ruler-rotation").insert("top", true)
+ config.at("ruler-rotation").insert("bottom", true)
+ } else {
+ config.at("ruler-rotation").insert(pos, true)
+ }
+ } else if kind == "unrotate-ruler" {
+ let pos = command.at("position")
+ if pos == none or pos == "n" {
+ config.at("ruler-rotation").insert("top", false)
+ config.at("ruler-rotation").insert("bottom", false)
+ } else {
+ config.at("ruler-rotation").insert(pos, false)
+ }
+ } else if kind == "start-number" {
+ config.at("start-numbers").insert(str(command.at("sequence")), command.at("start"))
+ if command.at("selection") != none {
+ config.at("sequence-windows").push((kind: "sequence-window", sequence: command.at("sequence"), selection: command.at("selection"), start: none))
+ }
+ } else if kind == "allow-zero-numbering" {
+ config.insert("allow-zero", true)
+ } else if kind == "disallow-zero-numbering" {
+ config.insert("allow-zero", false)
+ } else if kind == "sequence-length" {
+ config.at("sequence-lengths").insert(str(command.at("sequence")), command.at("length"))
+ } else if kind == "sequence-window" {
+ if command.at("start") != none {
+ config.at("start-numbers").insert(str(command.at("sequence")), command.at("start"))
+ }
+ config.at("sequence-windows").push(command)
+ } else if kind == "region-highlight" {
+ config.at("shade-regions").push(command)
+ } else if kind == "region-tint" {
+ config.at("tint-regions").push(command)
+ } else if kind == "tint-default" {
+ config.insert("tint-default", command.at("effect"))
+ } else if kind == "region-lower" {
+ config.at("lower-regions").push(command)
+ } else if kind == "region-emphasis" {
+ config.at("emph-regions").push(command)
+ } else if kind == "emphasis-default" {
+ config.insert("emph-default", command.at("style"))
+ } else if kind == "frame-block" {
+ config.at("frame-regions").push(command)
+ } else if kind == "hide-sequence" {
+ for ref in _ref-list(command.at("sequence")) {
+ config.at("hidden").push(ref)
+ }
+ } else if kind == "hide-all-sequences" {
+ config.insert("hide-seqs", true)
+ } else if kind == "show-all-sequences" {
+ config.insert("hide-seqs", false)
+ } else if kind == "remove-sequence" {
+ for ref in _ref-list(command.at("sequence")) {
+ config.at("killed").push(ref)
+ }
+ } else if kind == "no-shade" {
+ for ref in _ref-list(command.at("sequences")) {
+ config.at("no-shade").push(ref)
+ }
+ } else if kind == "separation-line" {
+ for ref in _ref-list(command.at("sequence")) {
+ config.at("separation-lines").push(ref)
+ }
+ } else if kind == "sequence-order" {
+ config.insert("order", _ref-list(command.at("order")))
+ } else if kind == "feature" {
+ config.at("features").push(command)
+ } else if kind == "feature-rule" {
+ config.insert("feature-rule", command.at("thickness"))
+ } else if kind == "codon" {
+ let residue = _upper(str(command.at("residue")))
+ let choices = str(command.at("triplets")).split(",").map(item => item.trim()).filter(item => item != "")
+ if choices.len() > 0 {
+ config.at("genetic-code").insert(residue, choices.last())
+ }
+ } else if kind == "genetic-code" {
+ let name = str(command.at("name"))
+ config.insert("genetic-code", _standard-genetic-code + if name == "ciliate" { (Q: "YAR") } else { (:) })
+ } else if kind == "backtranslation-label" {
+ config.at("backtranslation").insert("label-style", command.at("style"))
+ config.at("backtranslation").insert("label-size", command.at("size"))
+ } else if kind == "backtranslation-text" {
+ config.at("backtranslation").insert("text-style", command.at("style"))
+ config.at("backtranslation").insert("text-size", command.at("size"))
+ } else if kind == "feature-text-label" {
+ config.at("feature-text-names").insert(command.at("position"), command.at("name"))
+ } else if kind == "feature-style-label" {
+ config.at("feature-style-names").insert(command.at("position"), command.at("name"))
+ } else if kind == "hide-feature-text-label" {
+ config.at("feature-text-names").insert(command.at("position"), "")
+ } else if kind == "hide-feature-style-label" {
+ config.at("feature-style-names").insert(command.at("position"), "")
+ } else if kind == "hide-feature-text-labels" {
+ config.insert("feature-text-names", (:))
+ } else if kind == "hide-feature-style-labels" {
+ config.insert("feature-style-names", (:))
+ } else if kind == "feature-text-label-color" {
+ config.at("feature-text-name-colors").insert("default", command.at("color"))
+ } else if kind == "feature-style-label-color" {
+ config.at("feature-style-name-colors").insert("default", command.at("color"))
+ } else if kind == "feature-text-label-color-at" {
+ config.at("feature-text-name-colors").insert(command.at("position"), command.at("color"))
+ } else if kind == "feature-style-label-color-at" {
+ config.at("feature-style-name-colors").insert(command.at("position"), command.at("color"))
+ } else if kind == "consensus-from-sequence" {
+ config.at("consensus").insert("source", command.at("sequence"))
+ } else if kind == "consensus-from-all-sequences" {
+ config.at("consensus").insert("source", "all")
+ } else if kind == "show-leading-gaps" {
+ config.insert("show-leading-gaps", true)
+ } else if kind == "hide-leading-gaps" {
+ config.insert("show-leading-gaps", false)
+ } else if kind == "shift-single-sequence" {
+ config.insert("single-seq-shift", command.at("value"))
+ } else if kind == "keep-single-sequence-gaps" {
+ config.insert("keep-single-seq-gaps", true)
+ } else if kind == "hide-residues" {
+ config.insert("hide-residues", true)
+ } else if kind == "show-residues" {
+ config.insert("hide-residues", false)
+ } else if kind == "gap-char" {
+ config.at("gaps").insert("char", command.at("symbol"))
+ } else if kind == "gap-rule" {
+ config.at("gaps").insert("rule-thickness", command.at("thickness"))
+ } else if kind == "gap-colors" {
+ config.at("gaps").insert("fg", command.at("foreground"))
+ config.at("gaps").insert("bg", command.at("background"))
+ } else if kind == "stop-char" {
+ config.insert("stop-char", command.at("symbol"))
+ } else if kind == "peptide-groups" {
+ config.insert("pep-groups", _group-list(command.at("groups")))
+ } else if kind == "dna-groups" {
+ config.insert("dna-groups", _group-list(command.at("groups")))
+ } else if kind == "peptide-similarities" {
+ config.at("pep-sims").insert(_upper(str(command.at("residue"))), _upper(str(command.at("similars"))))
+ } else if kind == "dna-similarities" {
+ config.at("dna-sims").insert(_upper(str(command.at("residue"))), _upper(str(command.at("similars"))))
+ } else if kind == "residue-style" {
+ config.at("residue-style").insert(command.at("target"), _style-record(command.at("fg"), command.at("bg"), command.at("case"), command.at("style")))
+ } else if kind == "cell-style" {
+ config.at("cell-styles").push(command.at("callback"))
+ } else if kind == "clear-functional-groups" {
+ config.at("functional-groups").insert("custom", ())
+ config.insert("functional-default", "custom")
+ } else if kind == "functional-group" {
+ let groups = config.at("functional-groups").at("custom", default: ())
+ groups.push(_func-style-record(command.at("name"), command.at("residues"), command.at("fg"), command.at("bg"), command.at("case"), command.at("style")))
+ config.at("functional-groups").insert("custom", groups)
+ config.insert("functional-default", "custom")
+ } else if kind == "functional-style" {
+ config.at("functional-style-overrides").insert(_upper(str(command.at("residue"))), _style-record(command.at("fg"), command.at("bg"), command.at("case"), command.at("style")))
+ } else if kind == "bar-graph-stretch" {
+ let key = if command.at("position") == none { "default" } else { command.at("position") }
+ config.at("bar-graph-stretch").insert(key, command.at("value"))
+ } else if kind == "color-scale-stretch" {
+ let key = if command.at("position") == none { "default" } else { command.at("position") }
+ config.at("color-scale-stretch").insert(key, command.at("value"))
+ } else if kind == "alignment" {
+ config.insert("alignment", command.at("position"))
+ } else if kind == "character-stretch" {
+ config.insert("char-stretch", command.at("value"))
+ } else if kind == "line-stretch" {
+ config.insert("line-gap", config.at("font-size") * command.at("value"))
+ } else if kind == "numbering-width" {
+ config.insert("numbering-width-digits", command.at("digits"))
+ } else if kind == "fingerprint" {
+ config.insert("residues-per-line", command.at("value"))
+ config.at("names").insert("show", true)
+ config.at("names").insert("position", "left")
+ config.at("numbering").insert("show", false)
+ config.at("ruler").insert("steps", 100)
+ config.at("residue-style").insert("nomatch", _style-record("Black", "Gray10", "upper", "normal"))
+ } else if kind == "align-right-labels" {
+ config.insert("align-right-labels", true)
+ } else if kind == "align-left-labels" {
+ config.insert("align-right-labels", false)
+ } else if kind == "text-family" {
+ _set-text-style(config, command.at("target"), "family", command.at("value"))
+ } else if kind == "text-weight" {
+ _set-text-style(config, command.at("target"), "weight", command.at("value"))
+ } else if kind == "text-posture" {
+ _set-text-style(config, command.at("target"), "style", command.at("value"))
+ } else if kind == "text-size" {
+ _set-text-style(config, command.at("target"), "size", command.at("value"))
+ } else if kind == "text-style" {
+ _set-text-style(config, command.at("target"), "family", command.at("family"))
+ _set-text-style(config, command.at("target"), "weight", command.at("weight"))
+ _set-text-style(config, command.at("target"), "style", command.at("posture"))
+ _set-text-style(config, command.at("target"), "size", command.at("size"))
+ } else if kind == "caption" {
+ config.at("captions").insert(command.at("position"), command.at("text"))
+ } else if kind == "short-caption" {
+ config.at("captions").insert("short", command.at("text"))
+ } else if kind == "separator-space" {
+ config.insert("separation-space", command.at("value"))
+ } else if kind == "export-consensus" {
+ config.insert("exported-consensus", command)
+ } else if kind == "block-gap" {
+ config.insert("block-gap", command.at("value"))
+ } else if kind == "block-gap-factor" {
+ config.insert("block-gap", config.at("font-size") * command.at("value"))
+ } else if kind == "block-space-mode" {
+ config.insert("fixed-block-space", command.at("fixed"))
+ } else if kind == "line-gap" {
+ config.insert("line-gap", command.at("value"))
+ } else if kind == "feature-slot-space" {
+ config.at("feature-slot-spacing").insert(command.at("position"), command.at("value"))
+ }
+ config
+}
diff --git a/packages/preview/typshade/0.1.3/internal/engine/layout.typ b/packages/preview/typshade/0.1.3/internal/engine/layout.typ
new file mode 100644
index 0000000000..f0039ed707
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/engine/layout.typ
@@ -0,0 +1,394 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../model/pdb.typ": _pdb-selection-positions
+#import "../model/parser.typ": _chars, _lower, _upper
+
+#let _motif-pattern(selection) = {
+ let text = str(selection).trim()
+ if text == "" or text == "all" or text.contains("..") or text.contains(":") or text.matches(regex("^\\d+(?:\\s*,\\s*\\d+)*$")).len() > 0 {
+ return none
+ }
+ let pattern = ()
+ let idx = 0
+ while idx < text.len() {
+ let ch = text.slice(idx, idx + 1)
+ if ch == "[" {
+ let rest = text.slice(idx + 1)
+ let close = rest.position("]")
+ if close == none {
+ return none
+ }
+ pattern.push(_upper(rest.slice(0, close)))
+ idx += close + 2
+ } else if ch == "X" or ch == "x" {
+ pattern.push(none)
+ idx += 1
+ } else if ch.matches(regex("[A-Za-z]")).len() > 0 {
+ pattern.push(_upper(ch))
+ idx += 1
+ } else {
+ return none
+ }
+ }
+ pattern
+}
+
+#let _motif-columns(seq, selection) = {
+ let pattern = _motif-pattern(selection)
+ if pattern == none {
+ return none
+ }
+ let residues = ()
+ let columns = ()
+ for (idx, ch) in _chars(seq.at("aligned")).enumerate() {
+ if ch == "." or ch == "-" {
+ continue
+ }
+ residues.push(_upper(ch))
+ columns.push(idx)
+ }
+ let out = ()
+ if pattern.len() == 0 or residues.len() < pattern.len() {
+ return out
+ }
+ for start in range(0, residues.len() - pattern.len() + 1) {
+ let matched = true
+ for offset in range(0, pattern.len()) {
+ let allowed = pattern.at(offset)
+ if allowed != none and not allowed.contains(residues.at(start + offset)) {
+ matched = false
+ }
+ }
+ if matched {
+ for offset in range(0, pattern.len()) {
+ out.push(columns.at(start + offset))
+ }
+ }
+ }
+ out
+}
+
+#let _selection-fail(selection, reason) = {
+ assert(false, message: "typshade: invalid selection `" + str(selection) + "`: " + reason)
+}
+
+#let _is-gap(char) = char == "." or char == "-"
+
+#let _all-columns(seq) = {
+ let out = ()
+ for idx in range(0, seq.at("aligned").len()) {
+ out.push(idx)
+ }
+ out
+}
+
+#let _set-key(item) = str(item)
+
+#let _contains-column(items, column) = {
+ let key = _set-key(column)
+ for item in items {
+ if _set-key(item) == key {
+ return true
+ }
+ }
+ false
+}
+
+#let _union-columns(groups) = {
+ let out = ()
+ let seen = (:)
+ for group in groups {
+ for column in group {
+ let key = _set-key(column)
+ if not seen.keys().contains(key) {
+ seen.insert(key, true)
+ out.push(column)
+ }
+ }
+ }
+ out.sorted()
+}
+
+#let _intersection-columns(groups) = {
+ if groups.len() == 0 {
+ return ()
+ }
+ let out = ()
+ for column in groups.first() {
+ let present = true
+ for group in groups.slice(1) {
+ if not _contains-column(group, column) {
+ present = false
+ }
+ }
+ if present and not _contains-column(out, column) {
+ out.push(column)
+ }
+ }
+ out.sorted()
+}
+
+#let _difference-columns(base, remove) = base.filter(column => not _contains-column(remove, column))
+
+#let _selection-columns-from-positions(seq, positions) = {
+ let wanted = if type(positions) == array { positions } else { (positions,) }
+ let out = ()
+ for (idx, mapped) in seq.at("positions").enumerate() {
+ if mapped != none and wanted.contains(mapped) {
+ out.push(idx)
+ }
+ }
+ out
+}
+
+#let _selection-columns-from-range(seq, start, stop) = {
+ let out = ()
+ let low = calc.min(start, stop)
+ let high = calc.max(start, stop)
+ for (idx, pos) in seq.at("positions").enumerate() {
+ if pos != none and pos >= low and pos <= high {
+ out.push(idx)
+ }
+ }
+ out
+}
+
+#let _column-residues(alignment, column) = {
+ let residues = ()
+ for sequence in alignment.at("sequences") {
+ let ch = _upper(sequence.at("aligned").slice(column, column + 1))
+ if not _is-gap(ch) {
+ residues.push(ch)
+ }
+ }
+ residues
+}
+
+#let _column-metric(alignment, column, metric) = {
+ let name = _lower(str(metric))
+ let count = alignment.at("sequences").len()
+ let residues = _column-residues(alignment, column)
+ let covered = residues.len()
+ if name == "coverage" {
+ if count == 0 { 0.0 } else { covered * 100.0 / count }
+ } else if name == "gap-fraction" or name == "gaps" {
+ if count == 0 { 0.0 } else { (count - covered) * 100.0 / count }
+ } else if name == "entropy" {
+ if covered == 0 {
+ 0.0
+ } else {
+ let counts = (:)
+ for residue in residues {
+ counts.insert(residue, counts.at(residue, default: 0) + 1)
+ }
+ let total = 0.0
+ for value in counts.values() {
+ let p = value / covered
+ total -= p * calc.log(p) / calc.log(2)
+ }
+ total
+ }
+ } else if name == "conservation" or name == "identity" {
+ if covered == 0 {
+ 0.0
+ } else {
+ let counts = (:)
+ for residue in residues {
+ counts.insert(residue, counts.at(residue, default: 0) + 1)
+ }
+ let max-count = 0
+ for value in counts.values() {
+ if value > max-count {
+ max-count = value
+ }
+ }
+ max-count * 100.0 / covered
+ }
+ } else {
+ _selection-fail((kind: "typshade-selection", op: "metric", metric: metric), "unknown metric `" + str(metric) + "`")
+ }
+}
+
+#let _metric-passes(value, spec) = {
+ if spec.at("above", default: none) != none and not (value > spec.at("above")) {
+ return false
+ }
+ if spec.at("below", default: none) != none and not (value < spec.at("below")) {
+ return false
+ }
+ if spec.at("at-least", default: spec.at("min", default: none)) != none and value < spec.at("at-least", default: spec.at("min", default: none)) {
+ return false
+ }
+ if spec.at("at-most", default: spec.at("max", default: none)) != none and value > spec.at("at-most", default: spec.at("max", default: none)) {
+ return false
+ }
+ if spec.at("equals", default: none) != none and value != spec.at("equals") {
+ return false
+ }
+ true
+}
+
+#let _selection-columns-from-metric(seq, selection, alignment, resolve) = {
+ assert(alignment != none, message: "typshade: metric selections require alignment context")
+ let base = resolve(selection.at("selection", default: "all"))
+ let out = ()
+ for column in base {
+ let value = _column-metric(alignment, column, selection.at("metric", default: selection.at("name", default: "conservation")))
+ if _metric-passes(value, selection) {
+ out.push(column)
+ }
+ }
+ out
+}
+
+#let _pad-selection-columns(seq, columns, before, after) = {
+ if columns.len() == 0 {
+ return ()
+ }
+ let first = none
+ let last = none
+ for column in columns {
+ let pos = seq.at("positions").at(column)
+ if pos != none {
+ if first == none or pos < first {
+ first = pos
+ }
+ if last == none or pos > last {
+ last = pos
+ }
+ }
+ }
+ if first == none or last == none {
+ return columns
+ }
+ _selection-columns-from-range(seq, calc.max(1, first - before), last + after)
+}
+
+#let _selection-dsl-columns(seq, selection, alignment: none, resolve: none) = {
+ let op = selection.at("op", default: "or")
+ if op == "all" {
+ return _all-columns(seq)
+ }
+ if op == "range" {
+ let range-value = selection.at("range", default: none)
+ if range-value != none {
+ return resolve(range-value)
+ }
+ return _selection-columns-from-range(seq, int(selection.at("start")), int(selection.at("stop", default: selection.at("start"))))
+ }
+ if op == "positions" {
+ return _selection-columns-from-positions(seq, selection.at("positions"))
+ }
+ if op == "motif" {
+ let columns = _motif-columns(seq, selection.at("pattern"))
+ return if columns == none { () } else { columns }
+ }
+ if op == "metric" {
+ return _selection-columns-from-metric(seq, selection, alignment, resolve)
+ }
+ if op == "not" {
+ return _difference-columns(_all-columns(seq), resolve(selection.at("selection")))
+ }
+ if op == "pad" {
+ let before = int(selection.at("before", default: selection.at("padding", default: 0)))
+ let after = int(selection.at("after", default: before))
+ return _pad-selection-columns(seq, resolve(selection.at("selection")), before, after)
+ }
+ let items = selection.at("items", default: ())
+ let groups = items.map(item => resolve(item))
+ let columns = if op == "and" {
+ _intersection-columns(groups)
+ } else if op == "or" or op == "select" {
+ _union-columns(groups)
+ } else {
+ _selection-fail(selection, "unknown selection operation `" + str(op) + "`")
+ }
+ let padding = int(selection.at("padding", default: 0))
+ if padding != 0 {
+ _pad-selection-columns(seq, columns, padding, padding)
+ } else {
+ columns
+ }
+}
+
+#let _selection-columns(seq, selection, alignment: none) = {
+ if selection == none {
+ return ()
+ }
+ if type(selection) == dictionary and selection.at("kind", default: none) == "typshade-selection" {
+ return _selection-dsl-columns(seq, selection, alignment: alignment, resolve: item => _selection-columns(seq, item, alignment: alignment))
+ }
+ let pdb-positions = _pdb-selection-positions(selection)
+ if pdb-positions != none {
+ let values = ()
+ for (idx, mapped) in seq.at("positions").enumerate() {
+ if mapped != none and pdb-positions.contains(mapped) {
+ values.push(idx)
+ }
+ }
+ return values
+ }
+ let motif-columns = _motif-columns(seq, selection)
+ if motif-columns != none {
+ return motif-columns
+ }
+ let text = str(selection).trim()
+ if text.trim() == "all" {
+ return _all-columns(seq)
+ }
+ if text.contains(":") {
+ _selection-fail(selection, "expected a valid PDB selection such as point[1]:source,1[CA]")
+ }
+ if text.matches(regex("[A-Za-z\\[]")).len() > 0 {
+ _selection-fail(selection, "expected a residue range/list or a valid motif pattern")
+ }
+ let values = ()
+ for part in text.split(",") {
+ let trimmed = part.trim()
+ if trimmed == "" {
+ continue
+ }
+ if trimmed.contains("..") {
+ let bounds = trimmed.split("..")
+ assert(
+ bounds.len() == 2 and bounds.first().matches(regex("^\\d+$")).len() > 0 and bounds.last().matches(regex("^\\d+$")).len() > 0,
+ message: "typshade: invalid selection `" + str(selection) + "`: ranges must look like 1..10",
+ )
+ values += _selection-columns-from-range(seq, int(bounds.first()), int(bounds.last()))
+ } else {
+ assert(
+ trimmed.matches(regex("^\\d+$")).len() > 0,
+ message: "typshade: invalid selection `" + str(selection) + "`: entries must be residue numbers, ranges, motifs, or all",
+ )
+ values += _selection-columns-from-positions(seq, int(trimmed))
+ }
+ }
+ values
+}
+
+#let _sorted-unique(items) = {
+ let seen = (:)
+ let out = ()
+ for item in items {
+ let key = str(item)
+ if not seen.keys().contains(key) {
+ seen.insert(key, true)
+ out.push(item)
+ }
+ }
+ out.sorted()
+}
+
+#let _empty-cell() = (char: "", fg: "Black", bg: none, emph: false, frame: none)
+
+#let _ordinal-label(index) = {
+ let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ if index <= letters.len() {
+ letters.slice(index - 1, index)
+ } else {
+ str(index)
+ }
+}
+
+#let _selection-string(range) = str(range.at(0)) + ".." + str(range.at(1))
diff --git a/packages/preview/typshade/0.1.3/internal/interface/analysis.typ b/packages/preview/typshade/0.1.3/internal/interface/analysis.typ
new file mode 100644
index 0000000000..6198bff61b
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/interface/analysis.typ
@@ -0,0 +1,185 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../engine/layout.typ" as _layout
+#import "../model/logo.typ" as _logo
+#import "../model/palette.typ" as _palette
+#import "../model/parser.typ" as _parser
+
+#let _upper(text) = {
+ let lower = "abcdefghijklmnopqrstuvwxyz"
+ let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ let out = ""
+ for ch in str(text).clusters() {
+ let idx = lower.position(ch)
+ out += if idx == none { ch } else { upper.slice(idx, idx + 1) }
+ }
+ out
+}
+
+#let _is-gap(value) = {
+ let ch = str(value)
+ ch == "" or ch == "." or ch == "-"
+}
+
+#let _sequence-index(alignment, sequence) = _logo._resolve-sequence(alignment, sequence)
+
+#let _selected-columns(alignment, reference, selection) = {
+ if selection == none or selection == "all" {
+ return range(0, alignment.at("columns"))
+ }
+ let ref-index = _sequence-index(alignment, reference)
+ _layout._selection-columns(alignment.at("sequences").at(ref-index), selection, alignment: alignment)
+}
+
+#let _similar-residue(a, b, seq-type, similarities: none, groups: none) = {
+ let left = _upper(a)
+ let right = _upper(b)
+ if left == right {
+ return true
+ }
+ let resolved-sims = if similarities != none {
+ similarities
+ } else if seq-type == "N" {
+ _palette._dna-sims
+ } else {
+ _palette._pep-sims
+ }
+ let left-sims = resolved-sims.at(left, default: "")
+ let right-sims = resolved-sims.at(right, default: "")
+ if str(left-sims).contains(right) or str(right-sims).contains(left) {
+ return true
+ }
+ let resolved-groups = if groups != none {
+ groups
+ } else if seq-type == "N" {
+ _palette._dna-groups
+ } else {
+ _palette._pep-groups
+ }
+ for group in resolved-groups {
+ let text = _upper(group)
+ if text.contains(left) and text.contains(right) {
+ return true
+ }
+ }
+ false
+}
+
+#let _percent-score(
+ alignment,
+ sequence-a,
+ sequence-b,
+ kind,
+ selection: "all",
+ reference: auto,
+ seq-type: auto,
+ similarities: none,
+ groups: none,
+) = {
+ let left-index = _sequence-index(alignment, sequence-a)
+ let right-index = _sequence-index(alignment, sequence-b)
+ let left = alignment.at("sequences").at(left-index)
+ let right = alignment.at("sequences").at(right-index)
+ let resolved-reference = if reference == auto { sequence-a } else { reference }
+ let resolved-type = if seq-type == auto { alignment.at("seq-type") } else { _upper(seq-type) }
+ let total = 0
+ let hits = 0
+ for col in _selected-columns(alignment, resolved-reference, selection) {
+ let a = left.at("aligned").slice(col, col + 1)
+ let b = right.at("aligned").slice(col, col + 1)
+ if not _is-gap(a) and not _is-gap(b) {
+ total += 1
+ if _upper(a) == _upper(b) {
+ hits += 1
+ } else if kind == "similarity" and _similar-residue(a, b, resolved-type, similarities: similarities, groups: groups) {
+ hits += 1
+ }
+ }
+ }
+ if total == 0 {
+ 0.0
+ } else {
+ calc.round(hits / total * 1000) / 10
+ }
+}
+
+#let percent-identity(
+ source,
+ sequence-a,
+ sequence-b,
+ format: auto,
+ selection: "all",
+ reference: auto,
+ seq-type: auto,
+) = {
+ let alignment = _parser.read-alignment(source, format: format)
+ _percent-score(alignment, sequence-a, sequence-b, "identity", selection: selection, reference: reference, seq-type: seq-type)
+}
+
+#let percent-similarity(
+ source,
+ sequence-a,
+ sequence-b,
+ format: auto,
+ selection: "all",
+ reference: auto,
+ seq-type: auto,
+ similarities: none,
+ groups: none,
+) = {
+ let alignment = _parser.read-alignment(source, format: format)
+ _percent-score(
+ alignment,
+ sequence-a,
+ sequence-b,
+ "similarity",
+ selection: selection,
+ reference: reference,
+ seq-type: seq-type,
+ similarities: similarities,
+ groups: groups,
+ )
+}
+
+#let similarity-table(
+ source,
+ format: auto,
+ selection: "all",
+ reference: auto,
+ seq-type: auto,
+ similarities: none,
+ groups: none,
+) = {
+ let alignment = _parser.read-alignment(source, format: format)
+ let sequences = alignment.at("sequences")
+ let columns = (auto,)
+ let rows = ([],)
+ for seq in sequences {
+ columns.push(auto)
+ rows.push([#seq.at("name")])
+ }
+ for (row-index, row-seq) in sequences.enumerate() {
+ rows.push([#row-seq.at("name")])
+ for (col-index, col-seq) in sequences.enumerate() {
+ if row-index == col-index {
+ rows.push([-])
+ } else {
+ let kind = if col-index > row-index { "similarity" } else { "identity" }
+ let value = _percent-score(
+ alignment,
+ row-index + 1,
+ col-index + 1,
+ kind,
+ selection: selection,
+ reference: if reference == auto { row-index + 1 } else { reference },
+ seq-type: seq-type,
+ similarities: similarities,
+ groups: groups,
+ )
+ rows.push([#str(value)])
+ }
+ }
+ }
+ table(columns: columns, inset: (x: 5pt, y: 3pt), ..rows)
+}
diff --git a/packages/preview/typshade/0.1.3/internal/interface/annotations.typ b/packages/preview/typshade/0.1.3/internal/interface/annotations.typ
new file mode 100644
index 0000000000..8333bdaa5a
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/interface/annotations.typ
@@ -0,0 +1,53 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../engine/config.typ" as _config
+
+#let pdb-point(source, residue, distance: 1, atom: "side") = (
+ kind: "pdb-selection",
+ shape: "point",
+ source: source,
+ distance: distance,
+ anchors: ((residue: residue, atom: atom),),
+)
+
+#let pdb-line(source, residue-a, residue-b, distance: 1, atom-a: "side", atom-b: "side") = (
+ kind: "pdb-selection",
+ shape: "line",
+ source: source,
+ distance: distance,
+ anchors: ((residue: residue-a, atom: atom-a), (residue: residue-b, atom: atom-b)),
+)
+
+#let pdb-plane(source, residue-a, residue-b, residue-c, distance: 1, atom-a: "side", atom-b: "side", atom-c: "side") = (
+ kind: "pdb-selection",
+ shape: "plane",
+ source: source,
+ distance: distance,
+ anchors: ((residue: residue-a, atom: atom-a), (residue: residue-b, atom: atom-b), (residue: residue-c, atom: atom-c)),
+)
+
+#let highlight(sequence, selection, fg: "White", bg: "RoyalBlue", all: false) = _config.region-highlight(sequence, selection, fg, bg, apply-to-all: all)
+#let tint(sequence, selection, intensity: "medium") = _config.region-tint(sequence, selection, intensity: intensity)
+#let emphasize(sequence, selection, style: "italic") = _config.region-emphasis(sequence, selection, style: style)
+
+#let mark(position, sequence, selection, fill: "Yellow", text: "", style: none) = {
+ let resolved-style = if style == none { "box[" + str(fill) + "]" } else { style }
+ _config.feature(position, sequence, selection, style: resolved-style, text: text)
+}
+
+#let motif(sequence, selection, text: "motif", position: "top", fg: "White", bg: "RoyalBlue", fill: "Yellow", all: false) = (
+ highlight(sequence, selection, fg: fg, bg: bg, all: all),
+ mark(position, sequence, selection, fill: fill, text: text),
+)
+
+#let graph(position, sequence, selection, metric, kind: "bar", range: none, options: none, text: "") = {
+ let style = (
+ kind: str(kind),
+ metric: metric,
+ min: if range == none { none } else { range.at(0) },
+ max: if range == none { none } else { range.at(1) },
+ options: if options == none { () } else { options },
+ )
+ _config.feature(position, sequence, selection, style: style, text: text)
+}
diff --git a/packages/preview/typshade/0.1.3/internal/interface/controls.typ b/packages/preview/typshade/0.1.3/internal/interface/controls.typ
new file mode 100644
index 0000000000..c956dd8f23
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/interface/controls.typ
@@ -0,0 +1,191 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../engine/config.typ" as _config
+
+#let sequence-type(value) = _config.sequence-type(value)
+#let color-scheme(name) = _config.color-scheme(name)
+#let scoring-mode(name, option: none) = _config.scoring-mode(name, option: option)
+#let tcoffee-scores(source) = scoring-mode("T-Coffee", option: source)
+#let sequence-window(sequence, selection, start: none) = _config.sequence-window(sequence, selection, start: start)
+#let residues-per-line(value) = _config.residues-per-line(value)
+#let auto-layout(fit: "container", min: 1, max: none) = _config.auto-layout(fit: fit, min: min, max: max)
+#let auto-page(blocks: auto, repeat-legend: true) = _config.auto-page(blocks: blocks, repeat-legend: repeat-legend)
+#let threshold(value) = _config.threshold(value)
+#let shade-all-residues() = _config.shade-all-residues()
+#let all-match-threshold(value: 100) = _config.all-match-threshold(value: value)
+#let disable-all-match-threshold() = _config.disable-all-match-threshold()
+#let hide-all-match-positions() = _config.hide-all-match-positions()
+#let show-all-match-positions() = _config.show-all-match-positions()
+
+#let weight-table(name) = _config.weight-table(name)
+#let set-weight(residue-a, residue-b, value) = _config.set-weight(residue-a, residue-b, value)
+#let gap-penalty(value) = _config.gap-penalty(value)
+
+#let residue-style(target, fg, bg, case: "upper", style: "normal") = _config.residue-style(target, fg, bg, case: case, style: style)
+#let cell-style(callback) = _config.cell-style(callback)
+#let peptide-groups(groups) = _config.peptide-groups(groups)
+#let dna-groups(groups) = _config.dna-groups(groups)
+#let peptide-similarities(residue, similars) = _config.peptide-similarities(residue, similars)
+#let dna-similarities(residue, similars) = _config.dna-similarities(residue, similars)
+#let clear-functional-groups() = _config.clear-functional-groups()
+#let functional-group(name, residues, fg, bg, case: "upper", style: "normal") = _config.functional-group(name, residues, fg, bg, case: case, style: style)
+#let functional-style(residue, fg, bg, case: "upper", style: "normal") = _config.functional-style(residue, fg, bg, case: case, style: style)
+
+#let names-track(position: "left", color: none) = _config.names-track(position: position, color: color)
+#let no-names() = _config.no-names-track()
+#let numbering-track(position: "right", color: none) = _config.numbering-track(position: position, color: color)
+#let no-numbering() = _config.no-numbering-track()
+#let sequence-name(sequence, name) = _config.sequence-name(sequence, name)
+#let names-color(color) = _config.names-color(color)
+#let sequence-name-color(sequences, color) = _config.sequence-name-color(sequences, color)
+#let hide-sequence-name(sequences) = _config.hide-sequence-name(sequences)
+#let numbering-color(color) = _config.numbering-color(color)
+#let sequence-number-color(sequences, color) = _config.sequence-number-color(sequences, color)
+#let hide-sequence-number(sequences) = _config.hide-sequence-number(sequences)
+
+#let consensus-name(name) = _config.consensus-name(name)
+#let consensus-language(name) = _config.language(name)
+#let consensus-symbols(none-symbol, conserved-symbol, allmatch-symbol) = _config.consensus-symbols(none-symbol, conserved-symbol, allmatch-symbol)
+#let consensus-colors(none-fg: "Black", none-bg: "White", conserved-fg: "Black", conserved-bg: "White", allmatch-fg: "Black", allmatch-bg: "White") = _config.consensus-colors(
+ none-fg: none-fg,
+ none-bg: none-bg,
+ conserved-fg: conserved-fg,
+ conserved-bg: conserved-bg,
+ allmatch-fg: allmatch-fg,
+ allmatch-bg: allmatch-bg,
+)
+#let consensus-from-sequence(sequence) = _config.consensus-from-sequence(sequence)
+#let consensus-from-all-sequences() = _config.consensus-from-all-sequences()
+
+#let ruler-steps(value, position: none) = _config.ruler-steps(value, position: position)
+#let ruler-color(color, position: none) = _config.ruler-color(color, position: position)
+#let ruler-name(name, position: none) = _config.ruler-name(name, position: position)
+#let ruler-name-color(color, position: none) = _config.ruler-name-color(color, position: position)
+#let ruler-space(value, position: none) = _config.ruler-space(value, position: position)
+#let rotate-ruler(position: none) = _config.rotate-ruler(position)
+#let unrotate-ruler(position: none) = _config.unrotate-ruler(position)
+
+#let gap-char(symbol) = _config.gap-char(symbol)
+#let gap-rule(thickness) = _config.gap-rule(thickness)
+#let gap-colors(foreground, background) = _config.gap-colors(foreground, background)
+#let stop-char(symbol) = _config.stop-char(symbol)
+#let show-leading-gaps() = _config.show-leading-gaps()
+#let hide-leading-gaps() = _config.hide-leading-gaps()
+
+#let start-number(sequence, start, selection: none) = _config.start-number(sequence, start, selection: selection)
+#let allow-zero-numbering() = _config.allow-zero-numbering()
+#let disallow-zero-numbering() = _config.disallow-zero-numbering()
+#let sequence-length(sequence, length) = _config.sequence-length(sequence, length)
+#let domain(sequence, selection) = _config.domain(sequence, selection)
+#let domain-gap-rule(thickness) = _config.domain-gap-rule(thickness)
+#let domain-gap-colors(foreground, background) = _config.domain-gap-colors(foreground, background)
+
+#let highlight-block(sequence, selection, ..args) = _config.highlight-block(sequence, selection, ..args)
+#let region-color-scheme(sequence, selection, scheme) = _config.region-color-scheme(sequence, selection, scheme)
+#let lower(sequence, selection) = _config.region-lower(sequence, selection)
+#let lower-block(sequence, selection) = _config.lower-block(sequence, selection)
+#let emphasis-block(sequence, selection, style: "italic") = _config.emphasis-block(sequence, selection, style: style)
+#let tint-block(sequence, selection, intensity: "medium") = _config.tint-block(sequence, selection, intensity: intensity)
+#let tint-default(effect) = _config.tint-default(effect)
+#let emphasis-default(style) = _config.emphasis-default(style)
+#let frame(sequence, selection, color: "Red") = _config.frame-block(sequence, selection, color: color)
+
+#let hide-sequence(sequence) = _config.hide-sequence(sequence)
+#let hide-all-sequences() = _config.hide-all-sequences()
+#let show-all-sequences() = _config.show-all-sequences()
+#let remove-sequence(sequence) = _config.remove-sequence(sequence)
+#let no-shade(sequences) = _config.no-shade(sequences)
+#let separation-line(sequence) = _config.separation-line(sequence)
+#let sequence-order(order) = _config.sequence-order(order)
+
+#let feature-rule(thickness) = _config.feature-rule(thickness)
+#let codon(residue, triplets) = _config.codon(residue, triplets)
+#let genetic-code(name) = _config.genetic-code(name)
+#let backtranslation-label(..args) = _config.backtranslation-label(..args)
+#let backtranslation-text(..args) = _config.backtranslation-text(..args)
+#let feature-text-label(position, name) = _config.feature-text-label(position, name)
+#let feature-style-label(position, name) = _config.feature-style-label(position, name)
+#let hide-feature-text-label(position) = _config.hide-feature-text-label(position)
+#let hide-feature-style-label(position) = _config.hide-feature-style-label(position)
+#let hide-feature-text-labels() = _config.hide-feature-text-labels()
+#let hide-feature-style-labels() = _config.hide-feature-style-labels()
+#let feature-text-label-color(color) = _config.feature-text-label-color(color)
+#let feature-style-label-color(color) = _config.feature-style-label-color(color)
+#let feature-text-label-color-at(position, color) = _config.feature-text-label-color-at(position, color)
+#let feature-style-label-color-at(position, color) = _config.feature-style-label-color-at(position, color)
+
+#let frequency-correction() = _config.frequency-correction()
+#let no-frequency-correction() = _config.no-frequency-correction()
+#let subfamily(sequences) = _config.subfamily(sequences)
+#let sequence-logo-name(name) = _config.sequence-logo-name(name)
+#let subfamily-logo-name(name, negative-name: none) = _config.subfamily-logo-name(name, negative-name: negative-name)
+#let logo-scale(position: "leftright", color: "Black") = _config.logo-scale(position: position, color: color)
+#let no-logo-scale() = _config.no-logo-scale()
+#let logo-stretch(value) = _config.logo-stretch(value)
+#let negative-logo-values() = _config.negative-logo-values()
+#let no-negative-logo-values() = _config.no-negative-logo-values()
+#let relevance-threshold(value) = _config.relevance-threshold(value)
+#let relevance-marker(char: "*", color: "Black") = _config.relevance-marker(char: char, color: color)
+#let no-relevance-marker() = _config.no-relevance-marker()
+#let logo-color(residues, color) = _config.logo-color(residues, color)
+#let clear-logo-colors(default: "Black") = _config.clear-logo-colors(default: default)
+
+#let no-legend() = _config.no-legend-track()
+#let legend-color(color) = _config.legend-color(color)
+#let legend-offset(dx, dy) = _config.legend-offset(dx, dy)
+#let color-swatch(color) = _config.color-swatch(color)
+
+#let show-structure-types(format, types) = _config.show-structure-types(format, types)
+#let hide-structure-types(format, types) = _config.hide-structure-types(format, types)
+#let structure-appearance(format, structure-type, position, style, text) = _config.structure-appearance(format, structure-type, position, style, text)
+#let use-first-dssp-column() = _config.use-first-dssp-column()
+#let use-second-dssp-column() = _config.use-second-dssp-column()
+#let stride-track(sequence, source) = _config.stride-track(sequence, source)
+#let dssp-track(sequence, source) = _config.dssp-track(sequence, source)
+#let hmmtop-track(sequence, source, source-sequence: none) = _config.hmmtop-track(sequence, source, source-sequence: source-sequence)
+#let phd-topology-track(sequence, source) = _config.phd-topology-track(sequence, source)
+#let phd-secondary-track(sequence, source) = _config.phd-secondary-track(sequence, source)
+
+#let keep-single-sequence-gaps() = _config.keep-single-sequence-gaps()
+#let shift-single-sequence(value: -1) = _config.shift-single-sequence(value)
+#let hide-residues() = _config.hide-residues()
+#let show-residues() = _config.show-residues()
+#let bar-graph-stretch(value, position: none) = _config.bar-graph-stretch(value, position: position)
+#let color-scale-stretch(value, position: none) = _config.color-scale-stretch(value, position: position)
+#let alignment-position(position) = _config.alignment(position)
+#let character-stretch(value) = _config.character-stretch(value)
+#let line-stretch(value) = _config.line-stretch(value)
+#let numbering-width(digits) = _config.numbering-width(digits)
+#let fingerprint(value) = _config.fingerprint(value)
+#let align-right-labels() = _config.align-right-labels()
+#let align-left-labels() = _config.align-left-labels()
+
+#let text-family(target, family) = _config.text-family(target, family)
+#let text-weight(target, weight) = _config.text-weight(target, weight)
+#let text-posture(target, posture) = _config.text-posture(target, posture)
+#let text-size(target, size) = _config.text-size(target, size)
+#let text-style(target, family, weight, posture, size) = _config.text-style(target, family, weight, posture, size)
+
+#let caption(text, position: "bottom") = _config.caption(text, position: position)
+#let short-caption(text) = _config.short-caption(text)
+#let small-separator() = _config.small-separator()
+#let medium-separator() = _config.medium-separator()
+#let large-separator() = _config.large-separator()
+#let no-block-gap() = _config.no-block-gap()
+#let small-block-gap() = _config.small-block-gap()
+#let medium-block-gap() = _config.medium-block-gap()
+#let large-block-gap() = _config.large-block-gap()
+#let block-gap(value) = _config.block-gap(value)
+#let flexible-block-gap() = _config.flexible-block-gap()
+#let fixed-block-gap() = _config.fixed-block-gap()
+#let no-line-gap() = _config.no-line-gap()
+#let small-line-gap() = _config.small-line-gap()
+#let medium-line-gap() = _config.medium-line-gap()
+#let large-line-gap() = _config.large-line-gap()
+#let line-gap(value) = _config.line-gap(value)
+#let feature-slot-space(position, value) = _config.feature-slot-space(position, value)
+
+#let molecular-weight(sequence, unit: "Da") = _config.molweight(sequence, unit: unit)
+#let net-charge(sequence, termini: "o") = _config.charge(sequence, termini: termini)
+#let pdb-selection(selection) = _config.pdb-selection(selection)
diff --git a/packages/preview/typshade/0.1.3/internal/interface/data.typ b/packages/preview/typshade/0.1.3/internal/interface/data.typ
new file mode 100644
index 0000000000..c9bb2fae94
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/interface/data.typ
@@ -0,0 +1,18 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../model/parser.typ" as _parser
+
+#let alignment-data(source, format: auto) = _parser.read-alignment(source, format: format)
+
+#let parse-alignment(text, format: "fasta") = {
+ let source = _parser._source-text(text)
+ let normalized = _parser._lower(str(format))
+ if normalized == "msf" {
+ _parser.parse-msf(source)
+ } else if normalized == "aln" or normalized == "clustal" {
+ _parser.parse-aln(source)
+ } else {
+ _parser.parse-fasta(source)
+ }
+}
diff --git a/packages/preview/typshade/0.1.3/internal/interface/inspect.typ b/packages/preview/typshade/0.1.3/internal/interface/inspect.typ
new file mode 100644
index 0000000000..09d61c932b
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/interface/inspect.typ
@@ -0,0 +1,151 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../engine/layout.typ" as _layout
+#import "../engine/commands.typ" as _commands
+#import "../engine/config.typ" as _config
+#import "../model/logo.typ" as _logo
+#import "../model/parser.typ" as _parser
+#import "../render/alignment.typ" as _render
+
+#let _prepared-alignment(source, format, commands) = {
+ let config = _config._default-config()
+ let flat = ()
+ flat = _commands._add-command(flat, commands)
+ for command in flat {
+ config = _config._apply-command(config, command)
+ }
+ let alignment = _parser.read-alignment(source, format: format)
+ if config.at("seq-type") != none {
+ alignment.insert("seq-type", config.at("seq-type"))
+ }
+ let _ = _render._validate-config-sequence-refs(alignment, config)
+ _render._apply-single-sequence-options(alignment, config)
+ _render._apply-numbering-overrides(alignment, config)
+ (alignment: alignment, config: config)
+}
+
+#let _debug-value(value) = if value == auto { "auto" } else { str(value) }
+
+#let _selection-label(selection) = if type(selection) == dictionary and selection.at("kind", default: none) == "typshade-selection" {
+ selection.at("op", default: "select")
+} else {
+ str(selection)
+}
+
+#let alignment-summary(source, format: auto) = {
+ let alignment = _parser.read-alignment(source, format: format)
+ let names = alignment.at("sequences").map(seq => seq.at("name")).join(", ")
+ table(
+ columns: (auto, 1fr),
+ inset: (x: 6pt, y: 3pt),
+ stroke: (x, y) => if y == 0 { (bottom: 0.6pt) } else { none },
+ [Format], [#alignment.at("format")],
+ [Type], [#alignment.at("seq-type")],
+ [Sequences], [#str(alignment.at("sequences").len())],
+ [Columns], [#str(alignment.at("columns"))],
+ [Names], [#names],
+ )
+}
+
+#let selection-preview(source, sequence, selection, format: auto) = {
+ let alignment = _parser.read-alignment(source, format: format)
+ let resolved-sequence = alignment.at("sequences").at(_logo._resolve-sequence(alignment, sequence))
+ let positions = ()
+ for col in _layout._selection-columns(resolved-sequence, selection, alignment: alignment) {
+ let pos = resolved-sequence.at("positions").at(col)
+ if pos != none {
+ positions.push(str(pos))
+ }
+ }
+ if positions.len() == 0 { "" } else { positions.join(",") }
+}
+
+#let sequence-list(source, format: auto) = {
+ let alignment = _parser.read-alignment(source, format: format)
+ let rows = ([Name], [Length], [Non-gap residues])
+ for sequence in alignment.at("sequences") {
+ let count = sequence.at("positions").filter(pos => pos != none).len()
+ rows.push([#sequence.at("name")])
+ rows.push([#str(sequence.at("aligned").len())])
+ rows.push([#str(count)])
+ }
+ table(columns: (1fr, auto, auto), inset: (x: 5pt, y: 3pt), ..rows)
+}
+
+#let selection-table(source, ..items, format: auto, sequence: 1) = {
+ let rows = ([Name], [Selection], [Positions], [Count])
+ for item in items.pos() {
+ let selection = if type(item) == dictionary { item.at("selection") } else { item }
+ let name = if type(item) == dictionary and item.keys().contains("name") { item.at("name") } else { _selection-label(selection) }
+ let item-sequence = if type(item) == dictionary { item.at("sequence", default: sequence) } else { sequence }
+ let positions = selection-preview(source, item-sequence, selection, format: format)
+ let count = if positions == "" { 0 } else { positions.split(",").len() }
+ rows.push([#name])
+ rows.push([#_selection-label(selection)])
+ rows.push([#positions])
+ rows.push([#str(count)])
+ }
+ table(columns: (auto, auto, 1fr, auto), inset: (x: 5pt, y: 3pt), ..rows)
+}
+
+#let alignment-debug(source, format: auto, commands: ()) = {
+ let prepared = _prepared-alignment(source, format, commands)
+ let alignment = prepared.at("alignment")
+ let config = prepared.at("config")
+ let visible = _render._visible-sequences(alignment, config).map(seq => seq.at("name")).join(", ")
+ let display-columns = _render._display-columns(alignment, config)
+ table(
+ columns: (auto, 1fr),
+ inset: (x: 6pt, y: 3pt),
+ stroke: (x, y) => if y == 0 { (bottom: 0.6pt) } else { none },
+ [Field], [Value],
+ [Format], [#alignment.at("format")],
+ [Type], [#alignment.at("seq-type")],
+ [Sequences], [#str(alignment.at("sequences").len())],
+ [Visible sequences], [#visible],
+ [Columns], [#str(alignment.at("columns"))],
+ [Displayed columns], [#str(display-columns.len())],
+ [Residues per line], [#_debug-value(config.at("residues-per-line"))],
+ [Scoring mode], [#str(config.at("shading").at("mode"))],
+ [Consensus source], [#str(config.at("consensus").at("source", default: "all"))],
+ )
+}
+
+#let cell-inspect(source, sequence, column, format: auto, commands: ()) = {
+ let prepared = _prepared-alignment(source, format, commands)
+ let alignment = prepared.at("alignment")
+ let config = prepared.at("config")
+ let seq-index = _logo._resolve-sequence(alignment, sequence)
+ let sequence-data = alignment.at("sequences").at(seq-index)
+ let col = int(column) - 1
+ assert(col >= 0 and col < alignment.at("columns"), message: "typshade: column `" + str(column) + "` is out of range")
+ let info = _render._style-for-column(alignment, config, col)
+ let style-info = info.at("styles").at(seq-index)
+ let cell = (
+ char: style-info.at("char"),
+ fg: style-info.at("fg"),
+ bg: style-info.at("bg"),
+ emph: style-info.at("emph", default: false),
+ frame: none,
+ rule: style-info.at("rule", default: false),
+ )
+ cell = _render._apply-cell-styles(alignment, config, sequence-data, seq-index, col, info, style-info, cell)
+ cell = _render._apply-regions(alignment, config, seq-index, col, cell)
+ table(
+ columns: (auto, 1fr),
+ inset: (x: 6pt, y: 3pt),
+ stroke: (x, y) => if y == 0 { (bottom: 0.6pt) } else { none },
+ [Field], [Value],
+ [Sequence], [#sequence-data.at("name")],
+ [Column], [#str(column)],
+ [Position], [#str(sequence-data.at("positions").at(col))],
+ [Residue], [#sequence-data.at("aligned").slice(col, col + 1)],
+ [Kind], [#style-info.at("kind", default: "custom")],
+ [Rendered char], [#cell.at("char")],
+ [Foreground], [#str(cell.at("fg"))],
+ [Background], [#str(cell.at("bg"))],
+ [Consensus], [#str(info.at("consensus"))],
+ [Consensus score], [#str(calc.round(info.at("consensus-score") * 10) / 10)],
+ )
+}
diff --git a/packages/preview/typshade/0.1.3/internal/interface/presets.typ b/packages/preview/typshade/0.1.3/internal/interface/presets.typ
new file mode 100644
index 0000000000..15712af8c5
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/interface/presets.typ
@@ -0,0 +1,140 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../engine/config.typ" as _config
+#import "../engine/commands.typ" as _commands
+
+#let visual-theme(
+ colors: none,
+ mode: none,
+ option: none,
+ gap: none,
+ names: none,
+ numbering: none,
+ ruler: none,
+ legend: none,
+ commands: (),
+) = (
+ colors: colors,
+ mode: mode,
+ option: option,
+ gap: gap,
+ names: names,
+ numbering: numbering,
+ ruler: ruler,
+ legend: legend,
+ commands: commands,
+)
+
+#let _theme-commands(settings) = {
+ let out = ()
+ if settings.at("mode", default: none) != none {
+ out.push(_config.scoring-mode(settings.at("mode"), option: settings.at("option", default: none)))
+ }
+ if settings.at("colors", default: none) != none {
+ out.push(_config.color-scheme(settings.at("colors")))
+ }
+ let gap = settings.at("gap", default: none)
+ if type(gap) == dictionary {
+ if gap.at("foreground", default: none) != none or gap.at("background", default: none) != none {
+ out.push(_config.gap-colors(gap.at("foreground", default: "Black"), gap.at("background", default: "White")))
+ }
+ if gap.at("rule", default: none) != none {
+ out.push(_config.gap-rule(gap.at("rule")))
+ }
+ }
+ if settings.at("names", default: none) != none {
+ out.push(_config.names-color(settings.at("names")))
+ }
+ if settings.at("numbering", default: none) != none {
+ out.push(_config.numbering-color(settings.at("numbering")))
+ }
+ if settings.at("ruler", default: none) != none {
+ out.push(_config.ruler-color(settings.at("ruler")))
+ }
+ if settings.at("legend", default: none) != none {
+ out.push(_config.legend-color(settings.at("legend")))
+ }
+ out = _commands._add-command(out, settings.at("commands", default: ()))
+ out
+}
+
+#let shade-theme(name) = {
+ if name == none or name == "classic" {
+ ()
+ } else if type(name) == dictionary {
+ _theme-commands(name)
+ } else if name == "print" or name == "grayscale" {
+ (
+ _config.color-scheme("grays"),
+ _config.gap-colors("Gray60", "White"),
+ _config.names-color("Black"),
+ _config.numbering-color("Gray60"),
+ )
+ } else if name == "screen" or name == "blue" {
+ (
+ _config.color-scheme("blues"),
+ _config.names-color("RoyalBlue"),
+ _config.numbering-color("DarkGray"),
+ )
+ } else if name == "warm" {
+ (
+ _config.color-scheme("reds"),
+ _config.names-color("BrickRed"),
+ _config.numbering-color("DarkGray"),
+ )
+ } else if name == "nature" or name == "green" {
+ (
+ _config.color-scheme("greens"),
+ _config.names-color("PineGreen"),
+ _config.numbering-color("DarkGray"),
+ )
+ } else if type(name) == array {
+ name
+ } else {
+ ()
+ }
+}
+
+#let shade-preset(name) = {
+ if name == none {
+ ()
+ } else if name == "publication" {
+ (
+ shade-theme("print"),
+ _config.names-track(position: "left"),
+ _config.numbering-track(position: "right"),
+ _config.consensus-track(position: "bottom"),
+ _config.residues-per-line(60),
+ )
+ } else if name == "overview" {
+ (
+ _config.fingerprint(1000),
+ _config.no-numbering-track(),
+ _config.no-consensus-track(),
+ _config.align-left-labels(),
+ )
+ } else if name == "logo" {
+ (
+ _config.sequence-logo-track(position: "top"),
+ _config.logo-scale(position: "leftright"),
+ _config.consensus-track(position: "bottom"),
+ )
+ } else if name == "functional" {
+ (
+ _config.scoring-mode("functional", option: "charge"),
+ _config.legend-track(),
+ )
+ } else if name == "structure" {
+ (
+ _config.numbering-track(position: "leftright"),
+ _config.ruler-track(position: "top", steps: 10),
+ _config.ruler-color("DarkGray"),
+ _config.consensus-track(position: "bottom"),
+ )
+ } else if type(name) == array {
+ name
+ } else {
+ ()
+ }
+}
diff --git a/packages/preview/typshade/0.1.3/internal/interface/recipes.typ b/packages/preview/typshade/0.1.3/internal/interface/recipes.typ
new file mode 100644
index 0000000000..54eb1bcecd
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/interface/recipes.typ
@@ -0,0 +1,726 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../engine/config.typ" as _config
+#import "../engine/commands.typ" as _commands
+#import "../engine/layout.typ": _selection-columns
+#import "../model/logo.typ": _resolve-sequence
+#import "../model/parser.typ": read-alignment
+#import "annotations.typ" as _annotations
+#import "tracks.typ": sequence-logo, structure-tracks
+#import "presets.typ": shade-preset, shade-theme
+
+#let _recipe(name, options) = (kind: "typshade-recipe", name: name, options: options)
+
+#let _is-recipe(value) = type(value) == dictionary and value.at("kind", default: none) == "typshade-recipe"
+
+#let _add(out, value) = _commands._add-command(out, value)
+
+#let _value(value, key, default) = if type(value) == dictionary {
+ value.at(key, default: default)
+} else {
+ default
+}
+
+#let _track-position(value, default) = if value == true {
+ default
+} else if type(value) == dictionary {
+ value.at("position", default: default)
+} else {
+ value
+}
+
+#let _sequence-index(alignment, sequence) = {
+ _resolve-sequence(alignment, sequence)
+}
+
+#let _sequence(alignment, sequence) = alignment.at("sequences").at(_sequence-index(alignment, sequence))
+
+#let _clamp(value, low, high) = {
+ if value < low {
+ low
+ } else if value > high {
+ high
+ } else {
+ value
+ }
+}
+
+#let _selection-span(alignment, sequence, selection, padding: 0) = {
+ let seq = _sequence(alignment, sequence)
+ let columns = _selection-columns(seq, selection, alignment: alignment)
+ let first = none
+ let last = none
+ for col in columns {
+ let pos = seq.at("positions").at(col)
+ if pos != none {
+ if first == none or pos < first {
+ first = pos
+ }
+ if last == none or pos > last {
+ last = pos
+ }
+ }
+ }
+ if first == none or last == none {
+ none
+ } else {
+ let start = _clamp(first - padding, 1, seq.at("length"))
+ let stop = _clamp(last + padding, 1, seq.at("length"))
+ str(start) + ".." + str(stop)
+ }
+}
+
+#let _motif-patterns(motifs) = {
+ if motifs == none {
+ return ()
+ }
+ if type(motifs) == dictionary {
+ motifs.keys()
+ } else if type(motifs) == array {
+ let out = ()
+ for item in motifs {
+ if type(item) == str {
+ out.push(item)
+ } else if type(item) == dictionary {
+ out.push(_annotation-selection(item, "all"))
+ }
+ }
+ out
+ } else {
+ ()
+ }
+}
+
+#let _auto-region(alignment, sequence, motifs, padding: 8) = {
+ let seq = _sequence(alignment, sequence)
+ let first = none
+ let last = none
+ for pattern in _motif-patterns(motifs) {
+ let span = _selection-span(alignment, sequence, pattern)
+ if span != none {
+ let bounds = span.split("..")
+ let start = int(bounds.first())
+ let stop = int(bounds.last())
+ if first == none or start < first {
+ first = start
+ }
+ if last == none or stop > last {
+ last = stop
+ }
+ }
+ }
+ if first == none or last == none {
+ none
+ } else {
+ str(_clamp(first - padding, 1, seq.at("length"))) + ".." + str(_clamp(last + padding, 1, seq.at("length")))
+ }
+}
+
+#let _has-selection(alignment, sequence, selection) = _selection-span(alignment, sequence, selection) != none
+
+#let _auto-motifs(alignment, sequence) = {
+ let candidates = if alignment.at("seq-type") == "N" {
+ (
+ (pattern: "ATG", text: "start"),
+ (pattern: "TATA", text: "TATA box"),
+ )
+ } else {
+ (
+ (pattern: "NPA", text: "NPA"),
+ (pattern: "NXX[ST]N", text: "glycosylation"),
+ (pattern: "CXXC", text: "CXXC"),
+ (pattern: "HXXH", text: "HXXH"),
+ )
+ }
+ let out = (:)
+ for item in candidates {
+ if _has-selection(alignment, sequence, item.at("pattern")) {
+ out.insert(item.at("pattern"), item.at("text"))
+ }
+ }
+ out
+}
+
+#let _auto-line-length(alignment, purpose: "publication", region: none, sequence: 1) = {
+ if region != none and region != auto {
+ let span = _selection-span(alignment, sequence, region)
+ if span != none {
+ let bounds = span.split("..")
+ return _clamp(int(bounds.last()) - int(bounds.first()) + 1, 35, 90)
+ }
+ }
+ let columns = alignment.at("columns")
+ let count = alignment.at("sequences").len()
+ if purpose == "overview" {
+ if columns <= 120 { columns } else { 120 }
+ } else if columns <= 55 {
+ columns
+ } else if count >= 20 {
+ 80
+ } else if count >= 10 {
+ 70
+ } else {
+ 60
+ }
+}
+
+#let _auto-threshold(alignment, threshold) = {
+ if threshold != auto {
+ return threshold
+ }
+ if alignment.at("seq-type") == "N" {
+ 60
+ } else {
+ 45
+ }
+}
+
+#let _auto-logo(alignment, logo, region: none) = {
+ if logo != auto {
+ return logo
+ }
+ let count = alignment.at("sequences").len()
+ let columns = alignment.at("columns")
+ let compact = if region == none or region == auto {
+ columns <= 140
+ } else {
+ true
+ }
+ if count >= 4 and compact {
+ if alignment.at("seq-type") == "N" { "nucleotide" } else { "charge" }
+ } else {
+ false
+ }
+}
+
+#let _auto-conservation(alignment, value) = {
+ if value == auto {
+ alignment.at("sequences").len() >= 2
+ } else {
+ value
+ }
+}
+
+#let _apply-scoring(out, mode, colors, threshold, option: none) = {
+ if mode != none {
+ out.push(_config.scoring-mode(mode, option: option))
+ }
+ if colors != none {
+ out.push(_config.color-scheme(colors))
+ }
+ if threshold != none {
+ out.push(_config.threshold(threshold))
+ }
+ out
+}
+
+#let _apply-window(out, region, sequence, start: none) = {
+ if region != none {
+ if type(region) == dictionary {
+ out.push(_config.sequence-window(
+ region.at("sequence", default: sequence),
+ region.at("selection", default: region.at("range", default: "all")),
+ start: region.at("start", default: start),
+ ))
+ } else {
+ out.push(_config.sequence-window(sequence, region, start: start))
+ }
+ }
+ out
+}
+
+#let _apply-ruler(out, value, sequence, every) = {
+ if value == false or value == none {
+ return out
+ }
+ let position = _track-position(value, "top")
+ let steps = _value(value, "every", _value(value, "steps", every))
+ out.push(_config.ruler-track(
+ position: position,
+ sequence: _value(value, "sequence", sequence),
+ steps: steps,
+ color: _value(value, "color", none),
+ ))
+ if _value(value, "name", none) != none {
+ out.push(_config.ruler-name(_value(value, "name", none), position: position))
+ }
+ out
+}
+
+#let _apply-consensus(out, value) = {
+ if value == false or value == none {
+ return out
+ }
+ out.push(_config.consensus-track(
+ position: _track-position(value, "bottom"),
+ scale: _value(value, "scale", none),
+ name: _value(value, "name", none),
+ ))
+ out
+}
+
+#let _apply-logo(out, value) = {
+ if value == false or value == none {
+ return out
+ }
+ if value == true or type(value) == str {
+ out = _add(out, sequence-logo(position: "top", colors: if value == true { none } else { value }))
+ } else if type(value) == dictionary {
+ out = _add(out, sequence-logo(
+ position: value.at("position", default: "top"),
+ colors: value.at("colors", default: value.at("colorset", default: none)),
+ name: value.at("name", default: none),
+ scale: value.at("scale", default: "leftright"),
+ relevance-marker: value.at("relevance-marker", default: none),
+ stretch: value.at("stretch", default: none),
+ ))
+ }
+ out
+}
+
+#let _annotation-selection(item, default) = item.at("selection", default: item.at("range", default: item.at("pattern", default: default)))
+
+#let _add-motif(out, pattern, settings, sequence, position) = {
+ if type(settings) == str {
+ out = _add(out, _annotations.motif(sequence, pattern, text: settings, position: position))
+ } else if type(settings) == dictionary {
+ out = _add(out, _annotations.motif(
+ settings.at("sequence", default: sequence),
+ pattern,
+ text: settings.at("text", default: settings.at("label", default: "motif")),
+ position: settings.at("position", default: position),
+ fg: settings.at("fg", default: "White"),
+ bg: settings.at("bg", default: "RoyalBlue"),
+ fill: settings.at("fill", default: "Yellow"),
+ all: settings.at("all", default: false),
+ ))
+ } else if settings == true {
+ out = _add(out, _annotations.motif(sequence, pattern, position: position))
+ }
+ out
+}
+
+#let _apply-motifs(out, motifs, sequence, position: "top") = {
+ if motifs == none {
+ return out
+ }
+ if type(motifs) == dictionary {
+ for pattern in motifs.keys() {
+ out = _add-motif(out, pattern, motifs.at(pattern), sequence, position)
+ }
+ } else if type(motifs) == array {
+ for item in motifs {
+ if type(item) == str {
+ out = _add(out, _annotations.motif(sequence, item, position: position))
+ } else if type(item) == dictionary {
+ out = _add-motif(
+ out,
+ _annotation-selection(item, "all"),
+ item,
+ item.at("sequence", default: sequence),
+ item.at("position", default: position),
+ )
+ } else {
+ out = _add(out, item)
+ }
+ }
+ }
+ out
+}
+
+#let _add-highlight(out, selection, settings, sequence) = {
+ if type(settings) == dictionary {
+ out = _add(out, _annotations.highlight(
+ settings.at("sequence", default: sequence),
+ selection,
+ fg: settings.at("fg", default: "White"),
+ bg: settings.at("bg", default: settings.at("fill", default: "RoyalBlue")),
+ all: settings.at("all", default: false),
+ ))
+ } else if settings == true {
+ out = _add(out, _annotations.highlight(sequence, selection))
+ } else if type(settings) == str {
+ out = _add(out, _annotations.highlight(sequence, selection, bg: settings))
+ }
+ out
+}
+
+#let _apply-highlights(out, highlights, sequence) = {
+ if highlights == none {
+ return out
+ }
+ if type(highlights) == dictionary {
+ for selection in highlights.keys() {
+ out = _add-highlight(out, selection, highlights.at(selection), sequence)
+ }
+ } else if type(highlights) == array {
+ for item in highlights {
+ if type(item) == str {
+ out = _add(out, _annotations.highlight(sequence, item))
+ } else if type(item) == dictionary {
+ out = _add-highlight(out, _annotation-selection(item, "all"), item, sequence)
+ } else {
+ out = _add(out, item)
+ }
+ }
+ }
+ out
+}
+
+#let publication(
+ mode: "similar",
+ similarity: "blues",
+ threshold: auto,
+ sequence: 1,
+ region: auto,
+ line-length: auto,
+ ruler: true,
+ every: 10,
+ conservation: true,
+ logo: none,
+ motifs: (:),
+ highlights: (),
+ theme: none,
+ annotations: (),
+ commands: (),
+) = _recipe("publication", (
+ mode: mode,
+ similarity: similarity,
+ threshold: threshold,
+ sequence: sequence,
+ region: region,
+ line-length: line-length,
+ ruler: ruler,
+ every: every,
+ conservation: conservation,
+ logo: logo,
+ motifs: motifs,
+ highlights: highlights,
+ theme: theme,
+ annotations: annotations,
+ commands: commands,
+))
+
+#let _build-publication(alignment, options) = {
+ let mode = options.at("mode")
+ let similarity = options.at("similarity")
+ let threshold = _auto-threshold(alignment, options.at("threshold"))
+ let sequence = options.at("sequence")
+ let motifs = if options.at("motifs") == auto { _auto-motifs(alignment, sequence) } else { options.at("motifs") }
+ let region = if options.at("region") == auto { _auto-region(alignment, sequence, motifs) } else { options.at("region") }
+ let line-length = if options.at("line-length") == auto {
+ _auto-line-length(alignment, purpose: "publication", region: region, sequence: sequence)
+ } else {
+ options.at("line-length")
+ }
+ let conservation = _auto-conservation(alignment, options.at("conservation"))
+ let logo = _auto-logo(alignment, options.at("logo"), region: region)
+ let out = ()
+ out = _add(out, shade-preset("publication"))
+ out = _add(out, shade-theme(options.at("theme")))
+ out = _apply-scoring(out, mode, similarity, threshold)
+ if line-length != none {
+ out.push(_config.residues-per-line(line-length))
+ }
+ out = _apply-window(out, region, sequence)
+ out = _apply-ruler(out, options.at("ruler"), sequence, options.at("every"))
+ out = _apply-consensus(out, conservation)
+ out = _apply-logo(out, logo)
+ out = _apply-highlights(out, options.at("highlights"), sequence)
+ out = _apply-motifs(out, motifs, sequence)
+ out = _add(out, options.at("annotations"))
+ out = _add(out, options.at("commands"))
+ out
+}
+
+#let motif-map(
+ motifs,
+ sequence: 1,
+ region: auto,
+ line-length: auto,
+ similarity: "blues",
+ threshold: auto,
+ logo: auto,
+ conservation: auto,
+ graph: auto,
+ theme: none,
+ highlights: (),
+ commands: (),
+) = _recipe("motif-map", (
+ motifs: motifs,
+ sequence: sequence,
+ region: region,
+ line-length: line-length,
+ similarity: similarity,
+ threshold: threshold,
+ logo: logo,
+ conservation: conservation,
+ graph: graph,
+ theme: theme,
+ highlights: highlights,
+ commands: commands,
+))
+
+#let _build-motif-map(alignment, options) = {
+ let sequence = options.at("sequence")
+ let motifs = if options.at("motifs") == auto { _auto-motifs(alignment, sequence) } else { options.at("motifs") }
+ let region = if options.at("region") == auto { _auto-region(alignment, sequence, motifs) } else { options.at("region") }
+ let out = _build-publication(alignment, (
+ mode: "similar",
+ similarity: options.at("similarity"),
+ threshold: options.at("threshold"),
+ sequence: sequence,
+ region: region,
+ line-length: options.at("line-length"),
+ ruler: true,
+ every: 10,
+ conservation: options.at("conservation"),
+ logo: options.at("logo"),
+ motifs: motifs,
+ highlights: options.at("highlights"),
+ theme: options.at("theme"),
+ annotations: (),
+ commands: (),
+ ))
+ let graph = if options.at("graph") == auto { alignment.at("sequences").len() >= 3 } else { options.at("graph") }
+ if graph != false {
+ if type(graph) == dictionary {
+ out = _add(out, _annotations.graph(
+ graph.at("position", default: "bottom"),
+ graph.at("sequence", default: sequence),
+ graph.at("selection", default: graph.at("range", default: "all")),
+ graph.at("metric", default: "conservation"),
+ kind: graph.at("kind", default: "color"),
+ options: graph.at("options", default: ("ColdHot",)),
+ ))
+ } else {
+ out = _add(out, _annotations.graph("bottom", sequence, "all", "conservation", kind: "color", options: ("ColdHot",)))
+ }
+ }
+ out = _add(out, options.at("commands"))
+ out
+}
+
+#let structure-map(
+ sequence,
+ topology: none,
+ secondary: none,
+ hmmtop: none,
+ hmmtop-sequence: none,
+ region: none,
+ line-length: auto,
+ similarity: "grays",
+ threshold: auto,
+ ruler: true,
+ conservation: auto,
+ theme: none,
+ commands: (),
+) = _recipe("structure-map", (
+ sequence: sequence,
+ topology: topology,
+ secondary: secondary,
+ hmmtop: hmmtop,
+ hmmtop-sequence: hmmtop-sequence,
+ region: region,
+ line-length: line-length,
+ similarity: similarity,
+ threshold: threshold,
+ ruler: ruler,
+ conservation: conservation,
+ theme: theme,
+ commands: commands,
+))
+
+#let _build-structure-map(alignment, options) = {
+ let sequence = options.at("sequence")
+ let region = options.at("region")
+ let line-length = if options.at("line-length") == auto {
+ _auto-line-length(alignment, purpose: "structure", region: region, sequence: sequence)
+ } else {
+ options.at("line-length")
+ }
+ let threshold = if options.at("threshold") == auto { none } else { options.at("threshold") }
+ let conservation = _auto-conservation(alignment, options.at("conservation"))
+ let out = ()
+ out = _add(out, shade-preset("structure"))
+ out = _add(out, shade-theme(options.at("theme")))
+ out = _apply-scoring(out, "similar", options.at("similarity"), threshold)
+ if line-length != none {
+ out.push(_config.residues-per-line(line-length))
+ }
+ out = _apply-window(out, region, sequence)
+ out = _apply-ruler(out, options.at("ruler"), sequence, 10)
+ out = _apply-consensus(out, conservation)
+ out = _add(out, structure-tracks(
+ sequence,
+ hmmtop: options.at("hmmtop"),
+ topology: options.at("topology"),
+ secondary: options.at("secondary"),
+ hmmtop-sequence: options.at("hmmtop-sequence"),
+ ))
+ out = _add(out, options.at("commands"))
+ out
+}
+
+#let logo-analysis(
+ sequence: 1,
+ region: none,
+ line-length: auto,
+ colors: auto,
+ subfamily: none,
+ negative: auto,
+ relevance: auto,
+ conservation: auto,
+ theme: none,
+ commands: (),
+) = _recipe("logo-analysis", (
+ sequence: sequence,
+ region: region,
+ line-length: line-length,
+ colors: colors,
+ subfamily: subfamily,
+ negative: negative,
+ relevance: relevance,
+ conservation: conservation,
+ theme: theme,
+ commands: commands,
+))
+
+#let _build-logo-analysis(alignment, options) = {
+ let sequence = options.at("sequence")
+ let region = options.at("region")
+ let line-length = if options.at("line-length") == auto {
+ _auto-line-length(alignment, purpose: "logo", region: region, sequence: sequence)
+ } else {
+ options.at("line-length")
+ }
+ let colors = if options.at("colors") == auto {
+ if alignment.at("seq-type") == "N" { "nucleotide" } else { "charge" }
+ } else {
+ options.at("colors")
+ }
+ let conservation = _auto-conservation(alignment, options.at("conservation"))
+ let out = ()
+ out = _add(out, shade-preset("logo"))
+ out = _add(out, shade-theme(options.at("theme")))
+ if line-length != none {
+ out.push(_config.residues-per-line(line-length))
+ }
+ out = _apply-window(out, region, sequence)
+ out = _add(out, sequence-logo(position: "top", colors: colors))
+ if options.at("subfamily") != none {
+ out.push(_config.subfamily(options.at("subfamily")))
+ out.push(_config.subfamily-logo-track(position: "bottom", colorset: colors))
+ }
+ let negative = if options.at("negative") == auto { options.at("subfamily") != none } else { options.at("negative") }
+ if negative {
+ out.push(_config.negative-logo-values())
+ }
+ let relevance = if options.at("relevance") == auto and options.at("subfamily") != none { 1.0 } else { options.at("relevance") }
+ if relevance != none and relevance != auto {
+ if type(relevance) == dictionary {
+ out.push(_config.relevance-marker(
+ char: relevance.at("char", default: "*"),
+ color: relevance.at("color", default: "Black"),
+ ))
+ if relevance.at("threshold", default: none) != none {
+ out.push(_config.relevance-threshold(relevance.at("threshold")))
+ }
+ } else {
+ out.push(_config.relevance-threshold(relevance))
+ }
+ }
+ out = _apply-consensus(out, conservation)
+ out = _add(out, options.at("commands"))
+ out
+}
+
+#let overview(
+ mode: "similar",
+ colors: none,
+ line-length: auto,
+ names: true,
+ numbers: false,
+ conservation: false,
+ ruler: false,
+ theme: none,
+ commands: (),
+) = _recipe("overview", (
+ mode: mode,
+ colors: colors,
+ line-length: line-length,
+ names: names,
+ numbers: numbers,
+ conservation: conservation,
+ ruler: ruler,
+ theme: theme,
+ commands: commands,
+))
+
+#let _build-overview(alignment, options) = {
+ let line-length = if options.at("line-length") == auto {
+ _auto-line-length(alignment, purpose: "overview")
+ } else {
+ options.at("line-length")
+ }
+ let out = ()
+ out = _add(out, shade-preset("overview"))
+ out = _add(out, shade-theme(options.at("theme")))
+ out = _apply-scoring(out, options.at("mode"), options.at("colors"), none)
+ if line-length != none {
+ out.push(_config.residues-per-line(line-length))
+ }
+ let names = options.at("names")
+ if names == false {
+ out.push(_config.no-names-track())
+ } else if names != true {
+ out.push(_config.names-track(position: _track-position(names, "left"), color: _value(names, "color", none)))
+ }
+ let numbers = options.at("numbers")
+ if numbers == true or type(numbers) == dictionary or type(numbers) == str {
+ out.push(_config.numbering-track(position: _track-position(numbers, "right"), color: _value(numbers, "color", none)))
+ }
+ out = _apply-consensus(out, options.at("conservation"))
+ out = _apply-ruler(out, options.at("ruler"), 1, 10)
+ out = _add(out, options.at("commands"))
+ out
+}
+
+#let _build-recipe(alignment, item) = {
+ let name = item.at("name")
+ let options = item.at("options")
+ if name == "publication" {
+ _build-publication(alignment, options)
+ } else if name == "motif-map" {
+ _build-motif-map(alignment, options)
+ } else if name == "structure-map" {
+ _build-structure-map(alignment, options)
+ } else if name == "logo-analysis" {
+ _build-logo-analysis(alignment, options)
+ } else if name == "overview" {
+ _build-overview(alignment, options)
+ } else {
+ ()
+ }
+}
+
+#let resolve-figure(source, format, figure) = {
+ let out = ()
+ let items = if type(figure) == array { figure } else { (figure,) }
+ let needs-alignment = false
+ for item in items {
+ if _is-recipe(item) {
+ needs-alignment = true
+ }
+ }
+ let alignment = if needs-alignment { read-alignment(source, format: format) } else { none }
+ for item in items {
+ if _is-recipe(item) {
+ out = _add(out, _build-recipe(alignment, item))
+ } else {
+ out = _add(out, item)
+ }
+ }
+ out
+}
diff --git a/packages/preview/typshade/0.1.3/internal/interface/selection.typ b/packages/preview/typshade/0.1.3/internal/interface/selection.typ
new file mode 100644
index 0000000000..0a8db3943a
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/interface/selection.typ
@@ -0,0 +1,64 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#let _selection(op, fields) = {
+ let out = fields
+ out.insert("kind", "typshade-selection")
+ out.insert("op", op)
+ out
+}
+
+#let select(..items, mode: "or", padding: 0) = _selection(mode, (
+ items: items.pos(),
+ padding: padding,
+))
+
+#let select-or(..items, padding: 0) = select(..items.pos(), mode: "or", padding: padding)
+
+#let select-and(..items, padding: 0) = select(..items.pos(), mode: "and", padding: padding)
+
+#let select-not(selection) = _selection("not", (selection: selection))
+
+#let select-pad(selection, before, after: none) = _selection("pad", (
+ selection: selection,
+ before: before,
+ after: if after == none { before } else { after },
+))
+
+#let select-all() = _selection("all", (:))
+
+#let select-range(start, ..args) = {
+ let positional = args.pos()
+ let stop = if positional.len() > 0 { positional.first() } else { args.named().at("stop", default: none) }
+ if stop == none and type(start) == str {
+ _selection("range", (range: start))
+ } else {
+ _selection("range", (start: start, stop: if stop == none { start } else { stop }))
+ }
+}
+
+#let select-residues(..positions) = _selection("positions", (positions: positions.pos()))
+
+#let select-motif(pattern) = _selection("motif", (pattern: pattern))
+
+#let select-metric(
+ metric,
+ above: none,
+ below: none,
+ at-least: none,
+ at-most: none,
+ min: none,
+ max: none,
+ equals: none,
+ selection: "all",
+) = _selection("metric", (
+ metric: metric,
+ above: above,
+ below: below,
+ at-least: at-least,
+ at-most: at-most,
+ min: min,
+ max: max,
+ equals: equals,
+ selection: selection,
+))
diff --git a/packages/preview/typshade/0.1.3/internal/interface/shade.typ b/packages/preview/typshade/0.1.3/internal/interface/shade.typ
new file mode 100644
index 0000000000..1415770c2f
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/interface/shade.typ
@@ -0,0 +1,163 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../engine/config.typ" as _config
+#import "../engine/commands.typ" as _commands
+#import "recipes.typ" as _recipes
+#import "presets.typ": shade-preset, shade-theme
+#import "../render/alignment.typ" as _render
+
+#let _typst-figure = figure
+
+#let _position-value(value, default) = if value == true {
+ default
+} else {
+ value
+}
+
+#let _value-or-default(value, key, default) = if type(value) == dictionary {
+ value.at(key, default: default)
+} else {
+ default
+}
+
+#let shade(
+ source,
+ format: auto,
+ figure: (),
+ preset: none,
+ theme: none,
+ mode: none,
+ option: none,
+ seq-type: auto,
+ residues-per-line: none,
+ fit: none,
+ names: none,
+ numbering: none,
+ consensus: none,
+ ruler: none,
+ logo: none,
+ subfamily-logo: none,
+ legend: none,
+ regions: (),
+ features: (),
+ commands: (),
+ caption: none,
+ short-caption: none,
+ font: none,
+ font-size: none,
+) = {
+ let out = ()
+ out = _commands._add-command(out, _recipes.resolve-figure(source, format, figure))
+ out = _commands._add-command(out, shade-preset(preset))
+ out = _commands._add-command(out, shade-theme(theme))
+ if seq-type != auto and seq-type != none {
+ out.push(_config.sequence-type(seq-type))
+ }
+ if mode != none {
+ out.push(_config.scoring-mode(mode, option: option))
+ }
+ if residues-per-line != none {
+ out.push(_config.residues-per-line(residues-per-line))
+ }
+ if fit != none {
+ if type(fit) == dictionary {
+ let fit-mode = fit.at("mode", default: fit.at("fit", default: "container"))
+ out.push(_config.auto-layout(
+ fit: fit-mode,
+ min: fit.at("min", default: 1),
+ max: fit.at("max", default: none),
+ ))
+ if fit-mode == "page" or fit.at("page", default: false) != false {
+ let page = fit.at("page", default: false)
+ if type(page) == dictionary {
+ out.push(_config.auto-page(blocks: page.at("blocks", default: auto), repeat-legend: page.at("repeat-legend", default: true)))
+ } else {
+ out.push(_config.auto-page())
+ }
+ }
+ } else if fit != false {
+ out.push(_config.auto-layout(fit: if fit == true { "container" } else { fit }))
+ if fit == "page" {
+ out.push(_config.auto-page())
+ }
+ }
+ }
+ if names != none {
+ if names == false {
+ out.push(_config.no-names-track())
+ } else {
+ out.push(_config.names-track(position: _position-value(names, "left")))
+ }
+ }
+ if numbering != none {
+ if numbering == false {
+ out.push(_config.no-numbering-track())
+ } else {
+ out.push(_config.numbering-track(position: _position-value(numbering, "right")))
+ }
+ }
+ if consensus != none {
+ if consensus == false {
+ out.push(_config.no-consensus-track())
+ } else {
+ out.push(_config.consensus-track(position: _position-value(consensus, "bottom")))
+ }
+ }
+ if ruler != none {
+ if ruler == false {
+ out.push(_config.no-ruler-track())
+ } else if type(ruler) == dictionary {
+ out.push(_config.ruler-track(
+ position: ruler.at("position", default: "top"),
+ sequence: ruler.at("sequence", default: 1),
+ steps: ruler.at("steps", default: none),
+ color: ruler.at("color", default: none),
+ ))
+ } else {
+ out.push(_config.ruler-track(position: _position-value(ruler, "top")))
+ }
+ }
+ if logo != none {
+ if logo == false {
+ out.push(_config.no-sequence-logo-track())
+ } else if type(logo) == dictionary {
+ out.push(_config.sequence-logo-track(position: logo.at("position", default: "top"), colorset: logo.at("colorset", default: none)))
+ } else {
+ out.push(_config.sequence-logo-track(position: _position-value(logo, "top")))
+ }
+ }
+ if subfamily-logo != none {
+ if subfamily-logo == false {
+ out.push(_config.no-subfamily-logo-track())
+ } else if type(subfamily-logo) == dictionary {
+ if subfamily-logo.keys().contains("sequences") {
+ out.push(_config.subfamily(subfamily-logo.at("sequences")))
+ }
+ out.push(_config.subfamily-logo-track(position: subfamily-logo.at("position", default: "top"), colorset: subfamily-logo.at("colorset", default: none)))
+ } else {
+ out.push(_config.subfamily-logo-track(position: _position-value(subfamily-logo, "top")))
+ }
+ }
+ if legend != none {
+ if legend == false {
+ out.push(_config.no-legend-track())
+ } else if type(legend) == dictionary {
+ out.push(_config.legend-track(color: legend.at("color", default: "Black")))
+ } else {
+ out.push(_config.legend-track())
+ }
+ }
+ out = _commands._add-command(out, regions)
+ out = _commands._add-command(out, features)
+ out = _commands._add-command(out, commands)
+ if short-caption != none {
+ out.push(_config.short-caption(short-caption))
+ }
+ let rendered = _render.render-alignment(source, format: format, commands: out, font: font, font-size: font-size)
+ if caption == none {
+ rendered
+ } else {
+ _typst-figure(rendered, caption: caption)
+ }
+}
diff --git a/packages/preview/typshade/0.1.3/internal/interface/shortcuts.typ b/packages/preview/typshade/0.1.3/internal/interface/shortcuts.typ
new file mode 100644
index 0000000000..f9f4d097eb
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/interface/shortcuts.typ
@@ -0,0 +1,164 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../engine/config.typ" as _config
+#import "annotations.typ": highlight, tint, emphasize, mark, motif, graph
+#import "tracks.typ": consensus-track, ruler-track, sequence-logo, legend-track, structure-tracks
+
+#let _scoring(
+ mode,
+ colors: none,
+ threshold: none,
+ option: none,
+ all-match-threshold: none,
+ weight-table: none,
+ gap-penalty: none,
+) = {
+ let out = (_config.scoring-mode(mode, option: option),)
+ if colors != none {
+ out.push(_config.color-scheme(colors))
+ }
+ if threshold != none {
+ out.push(_config.threshold(threshold))
+ }
+ if all-match-threshold != none {
+ out.push(_config.all-match-threshold(value: all-match-threshold))
+ }
+ if weight-table != none {
+ out.push(_config.weight-table(weight-table))
+ }
+ if gap-penalty != none {
+ out.push(_config.gap-penalty(gap-penalty))
+ }
+ out
+}
+
+#let identical(colors: none, threshold: none, option: none, ..rest) = _scoring(
+ "identical",
+ colors: colors,
+ threshold: threshold,
+ option: option,
+ ..rest,
+)
+
+#let similar(colors: none, threshold: none, option: none, ..rest) = _scoring(
+ "similar",
+ colors: colors,
+ threshold: threshold,
+ option: option,
+ ..rest,
+)
+
+#let diverse(colors: none, threshold: none, option: none, ..rest) = _scoring(
+ "diverse",
+ colors: colors,
+ threshold: threshold,
+ option: option,
+ ..rest,
+)
+
+#let functional(kind, colors: none, threshold: none, ..rest) = _scoring(
+ "functional",
+ colors: colors,
+ threshold: threshold,
+ option: kind,
+ ..rest,
+)
+
+#let single-sequence(colors: none, threshold: none, sequence: none, ..rest) = _scoring(
+ "singleseq",
+ colors: colors,
+ threshold: threshold,
+ option: sequence,
+ ..rest,
+)
+
+#let tcoffee(source) = _config.scoring-mode("T-Coffee", option: source)
+
+#let lines(count) = _config.residues-per-line(count)
+
+#let window(sequence, selection, start: none) = _config.sequence-window(sequence, selection, start: start)
+
+#let names(position: "left", color: none) = _config.names-track(position: position, color: color)
+
+#let no-names() = _config.no-names-track()
+
+#let numbers(position: "right", color: none) = _config.numbering-track(position: position, color: color)
+
+#let no-numbers() = _config.no-numbering-track()
+
+#let consensus(position, scale: none, name: none) = consensus-track(position: position, scale: scale, name: name)
+
+#let no-consensus() = _config.no-consensus-track()
+
+#let ruler(position, sequence: 1, every: none, steps: none, color: none, name: none, name-color: none, space: none) = {
+ ruler-track(
+ position: position,
+ sequence: sequence,
+ steps: if steps == none { every } else { steps },
+ color: color,
+ name: name,
+ name-color: name-color,
+ space: space,
+ )
+}
+
+#let no-ruler(position: none) = _config.no-ruler-track(position: position)
+
+#let logo(position, colors: none, name: none, scale: "leftright", relevance-marker: none, stretch: none) = {
+ sequence-logo(
+ position: position,
+ colors: colors,
+ name: name,
+ scale: scale,
+ relevance-marker: relevance-marker,
+ stretch: stretch,
+ )
+}
+
+#let no-logo() = _config.no-sequence-logo-track()
+
+#let legend(color: "Black") = legend-track(color: color)
+
+#let no-legend() = _config.no-legend-track()
+
+#let structures(sequence, hmmtop: none, topology: none, secondary: none, hmmtop-sequence: none) = {
+ structure-tracks(
+ sequence,
+ hmmtop: hmmtop,
+ topology: topology,
+ secondary: secondary,
+ hmmtop-sequence: hmmtop-sequence,
+ )
+}
+
+#let gap-style(foreground: none, background: none, rule: none) = {
+ let out = ()
+ if foreground != none or background != none {
+ out.push(_config.gap-colors(
+ if foreground == none { "Black" } else { foreground },
+ if background == none { "White" } else { background },
+ ))
+ }
+ if rule != none {
+ out.push(_config.gap-rule(rule))
+ }
+ out
+}
+
+#let typography(target: "all", family: none, weight: none, posture: none, size: none) = {
+ let out = ()
+ if family != none {
+ out.push(_config.text-family(target, family))
+ }
+ if weight != none {
+ out.push(_config.text-weight(target, weight))
+ }
+ if posture != none {
+ out.push(_config.text-posture(target, posture))
+ }
+ if size != none {
+ out.push(_config.text-size(target, size))
+ }
+ out
+}
diff --git a/packages/preview/typshade/0.1.3/internal/interface/tracks.typ b/packages/preview/typshade/0.1.3/internal/interface/tracks.typ
new file mode 100644
index 0000000000..39923dd96a
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/interface/tracks.typ
@@ -0,0 +1,77 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../engine/config.typ" as _config
+
+#let consensus-track(position: "bottom", scale: none, name: none) = _config.consensus-track(position: position, scale: scale, name: name)
+#let no-consensus() = _config.no-consensus-track()
+
+#let ruler-track(position: "top", sequence: 1, steps: none, color: none, name: none, name-color: none, space: none) = {
+ let out = (_config.ruler-track(position: position, sequence: sequence, steps: steps, color: color),)
+ if name != none {
+ out.push(_config.ruler-name(name, position: position))
+ }
+ if color != none {
+ out.push(_config.ruler-color(color, position: position))
+ }
+ if name-color != none {
+ out.push(_config.ruler-name-color(name-color, position: position))
+ }
+ if space != none {
+ out.push(_config.ruler-space(space, position: position))
+ }
+ out
+}
+
+#let ruler-marker(number, text, position: "top", color: none) = _config.ruler-marker(number, text, position: position, color: color)
+#let no-ruler(position: none) = _config.no-ruler-track(position: position)
+
+#let sequence-logo(position: "top", colors: none, name: none, scale: "leftright", relevance-marker: none, stretch: none) = {
+ let out = (_config.sequence-logo-track(position: position, colorset: colors),)
+ if name != none {
+ out.push(_config.sequence-logo-name(name))
+ }
+ if scale == false {
+ out.push(_config.no-logo-scale())
+ } else if scale != none {
+ out.push(_config.logo-scale(position: scale))
+ }
+ if relevance-marker != none {
+ out.push(_config.relevance-marker(char: relevance-marker.at("char", default: "*"), color: relevance-marker.at("color", default: "Black")))
+ if relevance-marker.at("threshold", default: none) != none {
+ out.push(_config.relevance-threshold(relevance-marker.at("threshold")))
+ }
+ }
+ if stretch != none {
+ out.push(_config.logo-stretch(stretch))
+ }
+ out
+}
+
+#let no-sequence-logo() = _config.no-sequence-logo-track()
+
+#let subfamily-logo(sequences, position: "bottom", colors: none, name: none, negative-name: none) = {
+ let out = (_config.subfamily(sequences), _config.subfamily-logo-track(position: position, colorset: colors))
+ if name != none or negative-name != none {
+ out.push(_config.subfamily-logo-name(if name == none { "subfamily" } else { name }, negative-name: negative-name))
+ }
+ out
+}
+
+#let no-subfamily-logo() = _config.no-subfamily-logo-track()
+
+#let legend-track(color: "Black") = _config.legend-track(color: color)
+
+#let structure-tracks(sequence, hmmtop: none, topology: none, secondary: none, hmmtop-sequence: none) = {
+ let out = ()
+ if hmmtop != none {
+ out.push(_config.hmmtop-track(sequence, hmmtop, source-sequence: hmmtop-sequence))
+ }
+ if topology != none {
+ out.push(_config.phd-topology-track(sequence, topology))
+ }
+ if secondary != none {
+ out.push(_config.phd-secondary-track(sequence, secondary))
+ }
+ out
+}
diff --git a/packages/preview/typshade/0.1.3/internal/model/logo.typ b/packages/preview/typshade/0.1.3/internal/model/logo.typ
new file mode 100644
index 0000000000..9e16680b83
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/model/logo.typ
@@ -0,0 +1,181 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "palette.typ": _default-logo-colors, _functional-presets, _logo-color-presets
+#import "parser.typ": _upper
+
+#let _log2(value) = calc.log(value) / calc.log(2)
+#let _logo-max-bits(seq-type) = if seq-type == "N" { 2.0 } else { _log2(20.0) }
+
+#let _sequence-reference-message(alignment) = {
+ let names = alignment.at("sequences").map(seq => seq.at("name")).join(", ")
+ "expected a 1-based sequence index from 1 to " + str(alignment.at("sequences").len()) + " or one of: " + names
+}
+
+#let _resolve-sequence(alignment, sequence) = {
+ if type(sequence) == int {
+ assert(
+ sequence >= 1 and sequence <= alignment.at("sequences").len(),
+ message: "typshade: sequence index `" + str(sequence) + "` is out of range; " + _sequence-reference-message(alignment),
+ )
+ return sequence - 1
+ }
+ let ref = str(sequence)
+ for (idx, seq) in alignment.at("sequences").enumerate() {
+ if seq.at("name") == ref {
+ return idx
+ }
+ }
+ if ref.matches(regex("^\\d+$")).len() > 0 {
+ let index = int(ref)
+ assert(
+ index >= 1 and index <= alignment.at("sequences").len(),
+ message: "typshade: sequence index `" + ref + "` is out of range; " + _sequence-reference-message(alignment),
+ )
+ return index - 1
+ }
+ assert(
+ false,
+ message: "typshade: unknown sequence `" + ref + "`; " + _sequence-reference-message(alignment),
+ )
+}
+
+#let _functional-style(residue, mode) = {
+ if not _functional-presets.keys().contains(mode) {
+ return none
+ }
+ for group in _functional-presets.at(mode) {
+ if group.at("residues").contains(residue) {
+ return group
+ }
+ }
+ none
+}
+
+#let _logo-preset(seq-type, colorset) = {
+ if colorset == none {
+ return if seq-type == "N" { "nucleotide" } else { "rasmol" }
+ }
+ let key = str(colorset)
+ if key == "standard" {
+ return if seq-type == "N" { "nucleotide" } else { "rasmol" }
+ }
+ key
+}
+
+#let _logo-frequencies(alignment, col, subset: none, subfamily: false) = {
+ let counts = (:)
+ let rest = (:)
+ let total = 0
+ let total-rest = 0
+ let use-all = subset == none or subset.len() == 0
+ for (idx, seq) in alignment.at("sequences").enumerate() {
+ let char = _upper(seq.at("aligned").slice(col, col + 1))
+ if char == "." or char == "-" or char == "" {
+ continue
+ }
+ let in-subset = use-all or subset.contains(seq.at("name")) or subset.contains(idx + 1)
+ if not subfamily {
+ if in-subset {
+ counts.insert(char, counts.at(char, default: 0) + 1)
+ total += 1
+ }
+ } else if in-subset {
+ counts.insert(char, counts.at(char, default: 0) + 1)
+ total += 1
+ } else {
+ rest.insert(char, rest.at(char, default: 0) + 1)
+ total-rest += 1
+ }
+ }
+ if not subfamily {
+ return (counts: counts, total: total)
+ }
+ let diffs = (:)
+ for key in counts.keys() {
+ let left = if total == 0 { 0.0 } else { counts.at(key) / total }
+ let right = if total-rest == 0 { 0.0 } else { rest.at(key, default: 0) / total-rest }
+ diffs.insert(key, left - right)
+ }
+ for key in rest.keys() {
+ if not diffs.keys().contains(key) {
+ let right = if total-rest == 0 { 0.0 } else { rest.at(key) / total-rest }
+ diffs.insert(key, -right)
+ }
+ }
+ (counts: diffs, total: 1)
+}
+
+#let _logo-color(seq-type, colorset, residue) = {
+ let key = _upper(residue)
+ if type(colorset) == dictionary and colorset.keys().contains(key) {
+ return colorset.at(key)
+ }
+ let preset = _logo-preset(seq-type, colorset)
+ if _logo-color-presets.keys().contains(preset) {
+ for entry in _logo-color-presets.at(preset) {
+ if entry.at("residues").contains(key) {
+ return entry.at("color")
+ }
+ }
+ }
+ if colorset != none and _functional-presets.keys().contains(colorset) {
+ let group = _functional-style(key, colorset)
+ if group != none {
+ return group.at("bg")
+ }
+ }
+ _default-logo-colors.at(seq-type).at(key, default: "Black")
+}
+
+#let _logo-colorset(config, residue, subfamily: false) = {
+ let custom = config.at("logo-colors").at("map")
+ let key = _upper(residue)
+ if custom.keys().contains(key) or config.at("logo-colors").at("default") != none {
+ return custom + (default: config.at("logo-colors").at("default"))
+ }
+ if subfamily {
+ return config.at("subfamily-logo").at("colorset")
+ }
+ config.at("sequence-logo").at("colorset")
+}
+
+#let _logo-residue-color(config, seq-type, residue, subfamily: false) = {
+ _logo-color(seq-type, _logo-colorset(config, residue, subfamily: subfamily), residue)
+}
+
+#let _logo-column-items(alignment, config, col, subfamily: false) = {
+ let subset = if subfamily { config.at("subfamily") } else { none }
+ let data = _logo-frequencies(alignment, col, subset: subset, subfamily: subfamily)
+ let counts = data.at("counts")
+ if counts.keys().len() == 0 {
+ return ()
+ }
+ let seq-type = alignment.at("seq-type")
+ let heights = ()
+ if not subfamily {
+ let total = data.at("total")
+ let entropy = 0.0
+ for key in counts.keys() {
+ let p = counts.at(key) / total
+ if p > 0 {
+ entropy += -p * _log2(p)
+ }
+ }
+ let correction = if config.at("frequency-correction") and total > 0 {
+ (counts.keys().len() - 1) / (2.0 * calc.log(2) * total)
+ } else {
+ 0.0
+ }
+ let info = calc.max(0.0, _logo-max-bits(seq-type) - entropy - correction)
+ for key in counts.keys() {
+ let p = counts.at(key) / total
+ heights.push((residue: key, value: p * info))
+ }
+ } else {
+ for key in counts.keys() {
+ heights.push((residue: key, value: counts.at(key) * _logo-max-bits(seq-type)))
+ }
+ }
+ heights.sorted(key: it => calc.abs(it.at("value"))).filter(it => calc.abs(it.at("value")) > 0)
+}
diff --git a/packages/preview/typshade/0.1.3/internal/model/palette.typ b/packages/preview/typshade/0.1.3/internal/model/palette.typ
new file mode 100644
index 0000000000..88d5b3239e
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/model/palette.typ
@@ -0,0 +1,460 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#let _color-db = (
+ Black: (0, 0, 0),
+ White: (255, 255, 255),
+ Gray: (128, 128, 128),
+ Gray0: (255, 255, 255),
+ Gray5: (242, 242, 242),
+ Gray10: (230, 230, 230),
+ Gray15: (217, 217, 217),
+ Gray20: (204, 204, 204),
+ Gray25: (191, 191, 191),
+ Gray30: (179, 179, 179),
+ LightGray: (171, 171, 171),
+ Gray35: (166, 166, 166),
+ Gray40: (153, 153, 153),
+ Gray45: (140, 140, 140),
+ Gray50: (128, 128, 128),
+ Gray55: (115, 115, 115),
+ Gray60: (102, 102, 102),
+ Gray65: (89, 89, 89),
+ DarkGray: (87, 87, 87),
+ Gray70: (77, 77, 77),
+ Gray75: (64, 64, 64),
+ Gray80: (51, 51, 51),
+ Gray85: (38, 38, 38),
+ Gray90: (26, 26, 26),
+ Gray95: (13, 13, 13),
+ Red: (255, 0, 0),
+ Blue: (0, 0, 255),
+ Green: (0, 255, 0),
+ Magenta: (255, 0, 255),
+ Cyan: (0, 255, 255),
+ Yellow: (255, 255, 0),
+ Orange: (255, 99, 33),
+ YellowOrange: (255, 148, 0),
+ YellowGreen: (217, 255, 79),
+ Goldenrod: (255, 230, 41),
+ GreenYellow: (217, 255, 79),
+ RoyalBlue: (0, 128, 255),
+ RoyalPurple: (64, 25, 255),
+ PineGreen: (15, 191, 105),
+ OliveGreen: (55, 153, 8),
+ BrickRed: (184, 28, 15),
+ Mahagony: (166, 25, 22),
+ Brown: (102, 19, 0),
+ MidnightBlue: (3, 126, 255),
+ CornflowerBlue: (89, 222, 255),
+ Apricot: (255, 173, 122),
+ SkyBlue: (97, 255, 224),
+ RedViolet: (157, 17, 168),
+ CarnationPink: (255, 166, 255),
+ LightMagenta: (255, 128, 255),
+ LightBlue: (128, 128, 255),
+ LightGreen: (128, 255, 128),
+ LightRed: (255, 128, 128),
+ LightBrown: (179, 107, 89),
+ TC0: (153, 153, 255),
+ TC1: (102, 255, 77),
+ TC2: (153, 255, 77),
+ TC3: (204, 255, 0),
+ TC4: (255, 255, 0),
+ TC5: (255, 204, 0),
+ TC6: (255, 153, 0),
+ TC7: (255, 102, 0),
+ TC8: (255, 51, 0),
+ TC9: (255, 32, 0),
+ BlackWhite0: (0, 0, 0),
+ BlackWhite5: (13, 13, 13),
+ BlackWhite10: (26, 26, 26),
+ BlackWhite15: (38, 38, 38),
+ BlackWhite20: (51, 51, 51),
+ BlackWhite25: (64, 64, 64),
+ BlackWhite30: (77, 77, 77),
+ BlackWhite35: (89, 89, 89),
+ BlackWhite40: (102, 102, 102),
+ BlackWhite45: (115, 115, 115),
+ BlackWhite50: (128, 128, 128),
+ BlackWhite55: (140, 140, 140),
+ BlackWhite60: (153, 153, 153),
+ BlackWhite65: (166, 166, 166),
+ BlackWhite70: (179, 179, 179),
+ BlackWhite75: (191, 191, 191),
+ BlackWhite80: (204, 204, 204),
+ BlackWhite85: (217, 217, 217),
+ BlackWhite90: (230, 230, 230),
+ BlackWhite95: (242, 242, 242),
+ BlackWhite100: (255, 255, 255),
+ WhiteBlack0: (255, 255, 255),
+ WhiteBlack5: (242, 242, 242),
+ WhiteBlack10: (230, 230, 230),
+ WhiteBlack15: (217, 217, 217),
+ WhiteBlack20: (204, 204, 204),
+ WhiteBlack25: (191, 191, 191),
+ WhiteBlack30: (179, 179, 179),
+ WhiteBlack35: (166, 166, 166),
+ WhiteBlack40: (153, 153, 153),
+ WhiteBlack45: (140, 140, 140),
+ WhiteBlack50: (128, 128, 128),
+ WhiteBlack55: (115, 115, 115),
+ WhiteBlack60: (102, 102, 102),
+ WhiteBlack65: (89, 89, 89),
+ WhiteBlack70: (77, 77, 77),
+ WhiteBlack75: (64, 64, 64),
+ WhiteBlack80: (51, 51, 51),
+ WhiteBlack85: (38, 38, 38),
+ WhiteBlack90: (26, 26, 26),
+ WhiteBlack95: (13, 13, 13),
+ WhiteBlack100: (0, 0, 0),
+ BrewerA: (51, 255, 0),
+ BrewerC: (166, 237, 255),
+ BrewerG: (25, 179, 255),
+ BrewerT: (179, 255, 140),
+)
+
+#let _scale-db = (
+ BlueRed: (
+ (0, (26, 26, 128)),
+ (25, (110, 110, 173)),
+ (50, (219, 209, 209)),
+ (75, (232, 99, 94)),
+ (100, (222, 41, 10)),
+ ),
+ RedBlue: (
+ (0, (222, 38, 10)),
+ (25, (232, 99, 94)),
+ (50, (219, 209, 209)),
+ (75, (110, 110, 173)),
+ (100, (38, 43, 140)),
+ ),
+ GreenRed: (
+ (0, (0, 255, 0)),
+ (50, (115, 140, 0)),
+ (100, (242, 13, 0)),
+ ),
+ RedGreen: (
+ (0, (255, 0, 0)),
+ (50, (140, 115, 0)),
+ (100, (13, 242, 0)),
+ ),
+ ColdHot: (
+ (0, (0, 0, 255)),
+ (25, (0, 230, 255)),
+ (50, (0, 255, 10)),
+ (75, (250, 255, 0)),
+ (100, (232, 0, 0)),
+ ),
+ HotCold: (
+ (0, (255, 0, 0)),
+ (25, (255, 153, 0)),
+ (50, (41, 255, 0)),
+ (75, (0, 255, 222)),
+ (100, (0, 20, 255)),
+ ),
+ TCoffee: (
+ (0, (153, 153, 255)),
+ (10, (102, 255, 77)),
+ (20, (153, 255, 77)),
+ (30, (204, 255, 0)),
+ (40, (255, 255, 0)),
+ (50, (255, 204, 0)),
+ (60, (255, 153, 0)),
+ (70, (255, 102, 0)),
+ (80, (255, 51, 0)),
+ (90, (255, 32, 0)),
+ (100, (255, 32, 0)),
+ ),
+)
+
+#let _pep-groups = (
+ "FYW",
+ "ILVM",
+ "RK",
+ "DE",
+ "GA",
+ "ST",
+ "NQ",
+)
+
+#let _pep-sims = (
+ F: "YW",
+ Y: "WF",
+ W: "YF",
+ I: "LVM",
+ L: "VMI",
+ V: "MIL",
+ R: "KH",
+ K: "HR",
+ H: "RK",
+ A: "GS",
+ G: "A",
+ S: "TA",
+ T: "S",
+ D: "EN",
+ E: "DQ",
+ N: "QD",
+ Q: "NE",
+)
+
+#let _dna-groups = (
+ "GAR",
+ "CTY",
+)
+
+#let _dna-sims = (
+ A: "GR",
+ G: "AR",
+ R: "AG",
+ C: "TY",
+ T: "CY",
+ Y: "CT",
+)
+
+#let _functional-presets = (
+ charge: (
+ (name: "acidic (-)", residues: "DE", fg: "White", bg: "Red"),
+ (name: "basic (+)", residues: "KRH", fg: "White", bg: "Blue"),
+ ),
+ hydropathy: (
+ (name: "acidic (-)", residues: "DE", fg: "White", bg: "Red"),
+ (name: "basic (+)", residues: "KRH", fg: "White", bg: "Blue"),
+ (name: "polar uncharged", residues: "YSTGNQC", fg: "Black", bg: "Yellow"),
+ (name: "hydrophobic nonpolar", residues: "AFPMWVIL", fg: "White", bg: "Green"),
+ ),
+ structure: (
+ (name: "external", residues: "DEHKNQR", fg: "Black", bg: "Orange"),
+ (name: "ambivalent", residues: "ACGPSTWY", fg: "Black", bg: "Yellow"),
+ (name: "internal", residues: "FILMV", fg: "White", bg: "Green"),
+ ),
+ chemical: (
+ (name: "acidic (-)", residues: "DE", fg: "White", bg: "Red"),
+ (name: "aliphatic", residues: "AGVIL", fg: "White", bg: "Black"),
+ (name: "aliphatic (small)", residues: "AG", fg: "White", bg: "Gray"),
+ (name: "amide", residues: "NQ", fg: "White", bg: "Green"),
+ (name: "aromatic", residues: "FYW", fg: "White", bg: "Brown"),
+ (name: "basic (+)", residues: "KRH", fg: "White", bg: "Blue"),
+ (name: "hydroxyl", residues: "ST", fg: "Black", bg: "Magenta"),
+ (name: "imino", residues: "P", fg: "Black", bg: "Orange"),
+ (name: "sulfur", residues: "CM", fg: "Black", bg: "Yellow"),
+ ),
+ rasmol: (
+ (name: "Asp, Glu", residues: "DE", fg: "Red", bg: "White"),
+ (name: "Arg, Lys, His", residues: "KRH", fg: "Blue", bg: "White"),
+ (name: "Phe, Tyr, Trp", residues: "FYW", fg: "MidnightBlue", bg: "White"),
+ (name: "Ala, Gly", residues: "AG", fg: "Gray", bg: "White"),
+ (name: "Cys, Met", residues: "CM", fg: "Yellow", bg: "White"),
+ (name: "Ser, Thr", residues: "ST", fg: "Orange", bg: "White"),
+ (name: "Asn, Gln", residues: "NQ", fg: "Cyan", bg: "White"),
+ (name: "Leu, Val, Ile", residues: "LVI", fg: "Green", bg: "White"),
+ (name: "Pro", residues: "P", fg: "Apricot", bg: "White"),
+ ),
+ DNA: (
+ (name: "C", residues: "Cc", fg: "Black", bg: "BrewerC"),
+ (name: "G", residues: "Gg", fg: "White", bg: "BrewerG"),
+ (name: "A", residues: "Aa", fg: "Black", bg: "BrewerA"),
+ (name: "T,U", residues: "TtUu", fg: "Black", bg: "BrewerT"),
+ ),
+)
+
+#let _default-logo-colors = (
+ P: (
+ D: "Red", E: "Red",
+ C: "Yellow", M: "Yellow",
+ K: "Blue", R: "Blue",
+ S: "Orange", T: "Orange",
+ F: "MidnightBlue", Y: "MidnightBlue",
+ N: "Cyan", Q: "Cyan",
+ G: "LightGray",
+ L: "Green", V: "Green", I: "Green",
+ A: "DarkGray",
+ W: "LightMagenta",
+ H: "CornflowerBlue",
+ P: "Apricot",
+ B: "LightMagenta", Z: "LightMagenta",
+ ),
+ N: (
+ G: "Black",
+ A: "Green",
+ T: "Red",
+ U: "Red",
+ C: "Blue",
+ ),
+)
+
+#let _logo-color-presets = (
+ nucleotide: (
+ (residues: "G", color: "Black"),
+ (residues: "A", color: "Green"),
+ (residues: "TU", color: "Red"),
+ (residues: "C", color: "Blue"),
+ ),
+ rasmol: (
+ (residues: "DE", color: "Red"),
+ (residues: "CM", color: "Yellow"),
+ (residues: "KR", color: "Blue"),
+ (residues: "ST", color: "Orange"),
+ (residues: "FY", color: "MidnightBlue"),
+ (residues: "NQ", color: "Cyan"),
+ (residues: "G", color: "LightGray"),
+ (residues: "LVI", color: "Green"),
+ (residues: "A", color: "DarkGray"),
+ (residues: "W", color: "CarnationPink"),
+ (residues: "H", color: "CornflowerBlue"),
+ (residues: "P", color: "Apricot"),
+ (residues: "BZ", color: "LightMagenta"),
+ ),
+ chemical: (
+ (residues: "DE", color: "Red"),
+ (residues: "VIL", color: "Black"),
+ (residues: "AG", color: "Gray"),
+ (residues: "NQ", color: "Green"),
+ (residues: "FYW", color: "Brown"),
+ (residues: "KRH", color: "Blue"),
+ (residues: "ST", color: "Magenta"),
+ (residues: "P", color: "Orange"),
+ (residues: "CM", color: "Yellow"),
+ ),
+ hydropathy: (
+ (residues: "DE", color: "Red"),
+ (residues: "KRH", color: "Blue"),
+ (residues: "YSTGNQC", color: "Yellow"),
+ (residues: "AFPMWVIL", color: "Green"),
+ ),
+ structure: (
+ (residues: "DEHKNQR", color: "Orange"),
+ (residues: "ACGPSTWY", color: "Yellow"),
+ (residues: "FILMV", color: "Green"),
+ ),
+ "standard area": (
+ (residues: "G", color: "BrickRed"),
+ (residues: "AS", color: "Orange"),
+ (residues: "CP", color: "Yellow"),
+ (residues: "TDVN", color: "YellowGreen"),
+ (residues: "IE", color: "PineGreen"),
+ (residues: "LQHM", color: "SkyBlue"),
+ (residues: "FK", color: "RoyalPurple"),
+ (residues: "Y", color: "RedViolet"),
+ (residues: "RW", color: "Black"),
+ ),
+ "accessible area": (
+ (residues: "C", color: "BrickRed"),
+ (residues: "IVG", color: "Orange"),
+ (residues: "FLMA", color: "Yellow"),
+ (residues: "WSTH", color: "YellowGreen"),
+ (residues: "P", color: "PineGreen"),
+ (residues: "YDN", color: "SkyBlue"),
+ (residues: "EQ", color: "RoyalPurple"),
+ (residues: "R", color: "RedViolet"),
+ (residues: "K", color: "Black"),
+ ),
+ hardness: (
+ (residues: "ADEGILPQSTV", color: "BlueRed5"),
+ (residues: "KN", color: "BlueRed20"),
+ (residues: "R", color: "BlueRed40"),
+ (residues: "CFH", color: "BlueRed60"),
+ (residues: "MY", color: "BlueRed80"),
+ (residues: "W", color: "BlueRed100"),
+ ),
+ DNA: (
+ (residues: "A", color: "BrewerA"),
+ (residues: "C", color: "BrewerC"),
+ (residues: "G", color: "BrewerG"),
+ (residues: "TU", color: "BrewerT"),
+ ),
+)
+
+#let _rgb(tuple) = rgb(int(calc.round(tuple.at(0))), int(calc.round(tuple.at(1))), int(calc.round(tuple.at(2))))
+
+#let _mix-rgb(a, b, ratio) = (
+ int(calc.round(a.at(0) + (b.at(0) - a.at(0)) * ratio)),
+ int(calc.round(a.at(1) + (b.at(1) - a.at(1)) * ratio)),
+ int(calc.round(a.at(2) + (b.at(2) - a.at(2)) * ratio)),
+)
+
+#let _color-name(value) = if type(value) == str { value } else { str(value) }
+
+#let _rgb-components(value, default: "Black") = {
+ if value == none {
+ return _color-db.at(default)
+ }
+ let key = _color-name(value)
+ if _color-db.keys().contains(key) {
+ return _color-db.at(key)
+ }
+ let scale-hit = key.matches(regex("^([A-Za-z]+)(\\d+)$"))
+ if scale-hit.len() > 0 and _scale-db.keys().contains(scale-hit.first().captures.at(0)) {
+ let level = int(scale-hit.first().captures.at(1))
+ let points = _scale-db.at(scale-hit.first().captures.at(0))
+ if level <= points.first().at(0) {
+ return points.first().at(1)
+ }
+ if level >= points.last().at(0) {
+ return points.last().at(1)
+ }
+ for idx in range(0, points.len() - 1) {
+ let left = points.at(idx)
+ let right = points.at(idx + 1)
+ if level >= left.at(0) and level <= right.at(0) {
+ let span = right.at(0) - left.at(0)
+ let ratio = if span == 0 { 0.0 } else { (level - left.at(0)) / span }
+ return _mix-rgb(left.at(1), right.at(1), ratio)
+ }
+ }
+ }
+ _color-db.at(default)
+}
+
+#let resolve-color(value, default: "Black") = {
+ if value == none {
+ return _rgb(_color-db.at(default))
+ }
+ if type(value) == color {
+ return value
+ }
+ let key = _color-name(value)
+ if key.starts-with("#") {
+ return rgb(key)
+ }
+ if key.starts-with("rgb(") {
+ return rgb(key)
+ }
+ let scale-hit = key.matches(regex("^([A-Za-z]+)(\\d+)$"))
+ if scale-hit.len() > 0 and _scale-db.keys().contains(scale-hit.first().captures.at(0)) {
+ return scale-color(scale-hit.first().captures.at(0), int(scale-hit.first().captures.at(1)))
+ }
+ if _color-db.keys().contains(key) {
+ return _rgb(_color-db.at(key))
+ }
+ black
+}
+
+#let scale-color(name, value) = {
+ let clamped = calc.max(0, calc.min(100, value))
+ let key = _color-name(name)
+ if key == "BlackWhite" or key == "WhiteBlack" {
+ let scale-key = key + str(calc.round(clamped / 5) * 5)
+ return resolve-color(scale-key, default: "Black")
+ }
+ if not _scale-db.keys().contains(key) {
+ return resolve-color("Gray50")
+ }
+ let points = _scale-db.at(key)
+ if clamped <= points.first().at(0) {
+ return _rgb(points.first().at(1))
+ }
+ if clamped >= points.last().at(0) {
+ return _rgb(points.last().at(1))
+ }
+ for idx in range(0, points.len() - 1) {
+ let left = points.at(idx)
+ let right = points.at(idx + 1)
+ if clamped >= left.at(0) and clamped <= right.at(0) {
+ let span = right.at(0) - left.at(0)
+ let ratio = if span == 0 { 0.0 } else { (clamped - left.at(0)) / span }
+ return _rgb(_mix-rgb(left.at(1), right.at(1), ratio))
+ }
+ }
+ _rgb(points.last().at(1))
+}
diff --git a/packages/preview/typshade/0.1.3/internal/model/parser.typ b/packages/preview/typshade/0.1.3/internal/model/parser.typ
new file mode 100644
index 0000000000..6a55283130
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/model/parser.typ
@@ -0,0 +1,519 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#let _translate-case(text, from, to) = {
+ let out = ""
+ for ch in text.clusters() {
+ let idx = from.position(ch)
+ out += if idx == none { ch } else { to.slice(idx, idx + 1) }
+ }
+ out
+}
+
+#let _upper(text) = {
+ let lower = "abcdefghijklmnopqrstuvwxyz"
+ let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ _translate-case(str(text), lower, upper)
+}
+
+#let _lower(text) = {
+ let lower = "abcdefghijklmnopqrstuvwxyz"
+ let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ _translate-case(str(text), upper, lower)
+}
+
+#let _split-lines(text) = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
+
+#let _v15-or-later() = sys.version >= version(0, 15, 0)
+
+#let _source-is-path(source) = {
+ if _v15-or-later() {
+ type(source) == path
+ } else {
+ false
+ }
+}
+
+#let _looks-like-source-text(source) = {
+ let text = str(source)
+ let trimmed = text.trim()
+ trimmed == "" or text.contains("\n") or trimmed.starts-with(">") or trimmed.starts-with("CLUSTAL") or trimmed.starts-with("MUSCLE") or trimmed.starts-with("ATOM") or trimmed.starts-with("HETATM") or text.contains(" MSF: ") or text.contains("!!AA_MULTIPLE_ALIGNMENT") or text.contains("!!NA_MULTIPLE_ALIGNMENT") or trimmed.matches(regex("^\\S+\\s+[A-Za-z\\-\\.]+$")).len() > 0
+}
+
+#let _source-text(source) = {
+ if type(source) == bytes {
+ str(source)
+ } else if _source-is-path(source) {
+ str(read(source, encoding: none))
+ } else {
+ assert(
+ type(source) == str and _looks-like-source-text(source),
+ message: "typshade: source must be alignment text, bytes, or path(...); use read(..., encoding: none) or path(...) for files",
+ )
+ source
+ }
+}
+
+#let _strip-empty(lines) = {
+ let out = ()
+ for line in lines {
+ if line != "" {
+ out.push(line)
+ }
+ }
+ out
+}
+
+#let _chars(text) = {
+ let out = ()
+ for ch in str(text).clusters() {
+ out.push(ch)
+ }
+ out
+}
+
+#let _repeat(value, times) = {
+ let out = ()
+ for _ in range(0, times) {
+ out.push(value)
+ }
+ out
+}
+
+#let _array-fill(length, value) = {
+ let out = ()
+ for _ in range(0, length) {
+ out.push(if type(value) == dictionary { value + (:) } else { value })
+ }
+ out
+}
+
+#let _clone-array(items) = {
+ let out = ()
+ for item in items {
+ out.push(item)
+ }
+ out
+}
+
+#let _guess-seq-type(sequences) = {
+ let dna = "ACGTUNRYKMWSBDHV.-"
+ let protein = "ABCDEFGHIKLMNPQRSTVWXYZOUJ.-"
+ let dna-only = 0
+ let protein-only = 0
+ for seq in sequences {
+ for ch in _chars(_upper(seq.at("aligned"))) {
+ if protein.contains(ch) {
+ protein-only += 1
+ }
+ if dna.contains(ch) {
+ dna-only += 1
+ }
+ }
+ }
+ if sequences.len() == 0 or (dna-only > 0 and protein-only == dna-only) {
+ "N"
+ } else {
+ "P"
+ }
+}
+
+#let _build-sequence(name, aligned, metadata) = {
+ let positions = ()
+ let raw = ""
+ let pos = 0
+ for ch in _chars(aligned) {
+ if ch == "." or ch == "-" {
+ positions.push(none)
+ } else {
+ pos += 1
+ raw += ch
+ positions.push(pos)
+ }
+ }
+ (
+ name: name,
+ aligned: aligned,
+ raw: raw,
+ positions: positions,
+ length: pos,
+ metadata: metadata,
+ )
+}
+
+#let _validated-alignment(format, sequences, seq-type, name: "alignment") = {
+ assert(sequences.len() > 0, message: "typshade: " + format + " alignment is empty")
+ let columns = sequences.first().at("aligned").len()
+ for seq in sequences {
+ let length = seq.at("aligned").len()
+ assert(
+ length == columns,
+ message: "typshade: " + format + " alignment has inconsistent column length for sequence `" + seq.at("name") + "`: expected " + str(columns) + ", got " + str(length),
+ )
+ }
+ (
+ format: format,
+ name: name,
+ seq-type: seq-type,
+ columns: columns,
+ sequences: sequences,
+ )
+}
+
+#let parse-msf(text) = {
+ let lines = _split-lines(text)
+ let body = false
+ let blocks = (:)
+ let ignored = ()
+ let declared-type = none
+ for line in lines {
+ let trimmed = line.trim()
+ if trimmed == "//" {
+ body = true
+ continue
+ }
+ if not body {
+ let ignored-hit = trimmed.matches(regex("^!Name:\\s+(\\S+)"))
+ if ignored-hit.len() > 0 {
+ ignored.push(ignored-hit.first().captures.at(0))
+ }
+ let type-hit = trimmed.matches(regex("Type:\\s*([PN])"))
+ if type-hit.len() > 0 {
+ declared-type = type-hit.first().captures.at(0)
+ }
+ continue
+ }
+ if trimmed == "" {
+ continue
+ }
+ let hit = trimmed.matches(regex("^(\\S+)\\s+(.+)$"))
+ if hit.len() == 0 {
+ continue
+ }
+ let name = hit.first().captures.at(0)
+ if name.matches(regex("^\\d+$")).len() > 0 {
+ continue
+ }
+ let fragment = hit.first().captures.at(1).replace(" ", "")
+ if not blocks.keys().contains(name) {
+ blocks.insert(name, "")
+ }
+ blocks.insert(name, blocks.at(name) + fragment)
+ }
+ let sequences = ()
+ for name in blocks.keys() {
+ if not ignored.contains(name) {
+ sequences.push(_build-sequence(name, blocks.at(name), (:)))
+ }
+ }
+ _validated-alignment(
+ "MSF",
+ sequences,
+ if declared-type == none { _guess-seq-type(sequences) } else { declared-type },
+ )
+}
+
+#let parse-aln(text) = {
+ let lines = _split-lines(text)
+ let pieces = (:)
+ for line in lines {
+ if line.trim() == "" or line.starts-with("CLUSTAL") or line.starts-with("MUSCLE") or line.starts-with(" ") {
+ continue
+ }
+ let matches = line.matches(regex("^(\\S+)\\s+([A-Za-z\\-\\.]+)"))
+ if matches.len() == 0 {
+ continue
+ }
+ let hit = matches.first()
+ let name = hit.captures.at(0)
+ let fragment = hit.captures.at(1)
+ if not pieces.keys().contains(name) {
+ pieces.insert(name, "")
+ }
+ pieces.insert(name, pieces.at(name) + fragment)
+ }
+ let sequences = ()
+ for name in pieces.keys() {
+ sequences.push(_build-sequence(name, pieces.at(name), (:)))
+ }
+ _validated-alignment("ALN", sequences, _guess-seq-type(sequences))
+}
+
+#let parse-fasta(text) = {
+ let lines = _split-lines(text)
+ let sequences = ()
+ let current-name = none
+ let current-seq = ""
+ for line in lines {
+ if line.starts-with(">") {
+ if current-name != none {
+ sequences.push(_build-sequence(current-name, current-seq, (:)))
+ }
+ let label = line.slice(1).trim()
+ current-name = if label == "" { "seq" + str(sequences.len() + 1) } else { label.split(" ").first() }
+ current-seq = ""
+ } else if line.trim() != "" {
+ current-seq += line.trim()
+ }
+ }
+ if current-name != none {
+ sequences.push(_build-sequence(current-name, current-seq, (:)))
+ }
+ _validated-alignment("FASTA", sequences, _guess-seq-type(sequences))
+}
+
+#let _detected-format(format, text) = {
+ if format == auto {
+ if text.trim().starts-with(">") {
+ return "FASTA"
+ }
+ if text.contains(" MSF: ") {
+ return "MSF"
+ }
+ return "ALN"
+ }
+ let key = _upper(str(format))
+ if key == "AUTO" {
+ _detected-format(auto, text)
+ } else if key == "FASTA" or key == "FA" or key == "FAS" {
+ "FASTA"
+ } else if key == "MSF" {
+ "MSF"
+ } else if key == "ALN" or key == "CLUSTAL" or key == "MUSCLE" {
+ "ALN"
+ } else {
+ assert(
+ false,
+ message: "typshade: unknown alignment format `" + str(format) + "`; expected auto, fasta, msf, or aln",
+ )
+ }
+}
+
+#let read-alignment(source, format: auto) = {
+ let text = _source-text(source)
+ let detected = _detected-format(format, text)
+ if detected == "MSF" {
+ parse-msf(text)
+ } else if detected == "FASTA" {
+ parse-fasta(text)
+ } else {
+ parse-aln(text)
+ }
+}
+
+#let read-tcoffee(source) = {
+ let data = (:)
+ let lines = _split-lines(_source-text(source))
+ for line in lines {
+ let trimmed = line.trim()
+ let matches = trimmed.matches(regex("^(\\S+)\\s+\\d+\\s+([A-Za-z0-9\\-]+)\\s+\\d+$"))
+ if matches.len() == 0 {
+ continue
+ }
+ let name = matches.first().captures.at(0)
+ let fragment = matches.first().captures.at(1)
+ let scores = if data.keys().contains(name) { data.at(name) } else { () }
+ for ch in _chars(fragment) {
+ if ch == "-" {
+ scores.push(none)
+ } else if ch.matches(regex("\\d")).len() > 0 {
+ scores.push(int(ch) * 10)
+ } else {
+ scores.push(0)
+ }
+ }
+ data.insert(name, scores)
+ }
+ data
+}
+
+#let _run-lengths(chars, allowed) = {
+ let regions = ()
+ let active = false
+ let start = 0
+ for idx in range(0, chars.len()) {
+ let hit = allowed.contains(chars.at(idx))
+ if hit and not active {
+ active = true
+ start = idx + 1
+ }
+ if active and (not hit or idx == chars.len() - 1) {
+ let stop = if hit and idx == chars.len() - 1 { idx + 1 } else { idx }
+ regions.push((start, stop))
+ active = false
+ }
+ }
+ regions
+}
+
+#let read-hmmtop(source, sequence: none) = {
+ let text = _source-text(source)
+ if text.contains("Transmembrane helices:") {
+ let name = text.matches(regex("Protein:\\s*(\\S+)")).first().captures.at(0)
+ let orientation = text.matches(regex("N-terminus:\\s*(\\S+)")).first().captures.at(0)
+ let spans-line = text.matches(regex("Transmembrane helices:\\s*([0-9\\- ]+)")).first().captures.at(0)
+ let spans = ()
+ for token in spans-line.split(" ") {
+ if token.trim() == "" {
+ continue
+ }
+ let hit = token.matches(regex("^(\\d+)-(\\d+)$")).first()
+ spans.push((int(hit.captures.at(0)), int(hit.captures.at(1))))
+ }
+ return (name: name, orientation: orientation, spans: spans)
+ }
+ let lines = _split-lines(text)
+ for line in lines {
+ let trimmed = line.trim()
+ if not trimmed.starts-with(">HP:") {
+ continue
+ }
+ let hit = trimmed.matches(regex("^>HP:\\s*(\\d+)\\s+(\\S+)\\s+(\\S+)\\s+(\\d+)\\s+(.+)$")).first()
+ let name = hit.captures.at(1)
+ if sequence != none and name != str(sequence) {
+ continue
+ }
+ let orientation = hit.captures.at(2)
+ let values = _strip-empty(hit.captures.at(4).split(" "))
+ let spans = ()
+ let idx = 0
+ while idx + 1 < values.len() {
+ spans.push((int(values.at(idx)), int(values.at(idx + 1))))
+ idx += 2
+ }
+ return (name: name, orientation: orientation, spans: spans)
+ }
+ (name: "", orientation: "IN", spans: ())
+}
+
+#let read-phd-topology(source) = {
+ let runs = ""
+ for line in _split-lines(_source-text(source)) {
+ let matches = line.matches(regex("^\\s*PHDThtm\\s*\\|([^|]+)\\|"))
+ if matches.len() > 0 {
+ runs += matches.first().captures.at(0).replace(" ", "")
+ }
+ }
+ (
+ topology: runs,
+ spans: _run-lengths(_chars(runs), "T"),
+ internal: _run-lengths(_chars(runs), "iI"),
+ external: _run-lengths(_chars(runs), "oO"),
+ )
+}
+
+#let read-phd-secondary(source) = {
+ let runs = ""
+ for line in _split-lines(_source-text(source)) {
+ let matches = line.matches(regex("^\\s*PHD sec\\s*\\|([^|]+)\\|"))
+ if matches.len() > 0 {
+ runs += matches.first().captures.at(0)
+ }
+ }
+ (
+ sequence: runs,
+ alpha: _run-lengths(_chars(runs), "H"),
+ beta: _run-lengths(_chars(runs), "E"),
+ turn: _run-lengths(_chars(runs), "T"),
+ )
+}
+
+#let _runs-from-entries(entries, allowed) = {
+ let regions = ()
+ let active = false
+ let start = 0
+ let last = 0
+ for entry in entries {
+ let pos = entry.at("pos")
+ let code = entry.at("code")
+ let hit = allowed.contains(code)
+ let contiguous = active and pos == last + 1
+ if hit and (not active or not contiguous) {
+ if active {
+ regions.push((start, last))
+ }
+ active = true
+ start = pos
+ } else if not hit and active {
+ regions.push((start, last))
+ active = false
+ }
+ last = pos
+ }
+ if active {
+ regions.push((start, last))
+ }
+ regions
+}
+
+#let read-dssp(source, use-second-column: true) = {
+ let entries = ()
+ let started = false
+ for line in _split-lines(_source-text(source)) {
+ if not started {
+ if line.contains("RESIDUE AA STRUCTURE") {
+ started = true
+ }
+ continue
+ }
+ if line.trim() == "" or line.len() < 17 {
+ continue
+ }
+ let first-field = line.slice(0, calc.min(5, line.len())).trim()
+ let second-field = line.slice(calc.min(5, line.len()), calc.min(10, line.len())).trim()
+ let aa = line.slice(calc.min(13, line.len()), calc.min(14, line.len())).trim()
+ if aa == "" or aa == "!" {
+ continue
+ }
+ let code = line.slice(calc.min(16, line.len()), calc.min(17, line.len())).trim()
+ let pos = if use-second-column and second-field != "" {
+ int(second-field)
+ } else if first-field != "" {
+ int(first-field)
+ } else {
+ entries.len() + 1
+ }
+ entries.push((pos: pos, code: if code == "" { "C" } else { code }))
+ }
+ (
+ sequence: entries.map(entry => entry.at("code")).join(""),
+ alpha: _runs-from-entries(entries, "H"),
+ "3-10": _runs-from-entries(entries, "G"),
+ pi: _runs-from-entries(entries, "I"),
+ beta: _runs-from-entries(entries, "E"),
+ bridge: _runs-from-entries(entries, "B"),
+ turn: _runs-from-entries(entries, "T"),
+ bend: _runs-from-entries(entries, "S"),
+ )
+}
+
+#let read-stride(source) = {
+ let entries = ()
+ for line in _split-lines(_source-text(source)) {
+ let trimmed = line.trim()
+ if not trimmed.starts-with("ASG") {
+ continue
+ }
+ let hit = trimmed.matches(regex("^ASG\\s+\\S+\\s+\\S+\\s+(-?\\d+)\\s+(-?\\d+)\\s+([A-Z])"))
+ if hit.len() > 0 {
+ let captures = hit.first().captures
+ entries.push((pos: int(captures.at(1)), code: captures.at(2)))
+ continue
+ }
+ let fields = _strip-empty(trimmed.split(" "))
+ if fields.len() >= 6 {
+ let pos = int(fields.at(4))
+ let code = fields.at(5)
+ entries.push((pos: pos, code: code))
+ }
+ }
+ (
+ sequence: entries.map(entry => entry.at("code")).join(""),
+ alpha: _runs-from-entries(entries, "H"),
+ "3-10": _runs-from-entries(entries, "G"),
+ pi: _runs-from-entries(entries, "I"),
+ beta: _runs-from-entries(entries, "E"),
+ bridge: _runs-from-entries(entries, "B"),
+ turn: _runs-from-entries(entries, "T"),
+ )
+}
diff --git a/packages/preview/typshade/0.1.3/internal/model/pdb.typ b/packages/preview/typshade/0.1.3/internal/model/pdb.typ
new file mode 100644
index 0000000000..cd87131b4e
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/model/pdb.typ
@@ -0,0 +1,210 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "parser.typ": _source-text, _split-lines, _upper
+
+#let _field(line, start, stop) = if line.len() >= stop {
+ line.slice(start, stop).trim()
+} else {
+ ""
+}
+
+#let _vec-sub(a, b) = (
+ x: a.at("x") - b.at("x"),
+ y: a.at("y") - b.at("y"),
+ z: a.at("z") - b.at("z"),
+)
+
+#let _vec-dot(a, b) = a.at("x") * b.at("x") + a.at("y") * b.at("y") + a.at("z") * b.at("z")
+#let _vec-len2(a) = _vec-dot(a, a)
+
+#let _vec-cross(a, b) = (
+ x: a.at("y") * b.at("z") - a.at("z") * b.at("y"),
+ y: a.at("z") * b.at("x") - a.at("x") * b.at("z"),
+ z: a.at("x") * b.at("y") - a.at("y") * b.at("x"),
+)
+
+#let _read-pdb-atoms(source) = {
+ let atoms = ()
+ for line in _split-lines(_source-text(source)) {
+ if not (line.starts-with("ATOM") or line.starts-with("HETATM")) or line.len() < 54 {
+ continue
+ }
+ let res = _field(line, 22, 26)
+ let x = _field(line, 30, 38)
+ let y = _field(line, 38, 46)
+ let z = _field(line, 46, 54)
+ if res == "" or x == "" or y == "" or z == "" {
+ continue
+ }
+ atoms.push((
+ atom: _upper(_field(line, 12, 16)),
+ residue: int(res),
+ x: float(x),
+ y: float(y),
+ z: float(z),
+ ))
+ }
+ atoms
+}
+
+#let _atoms-for-residue(atoms, residue) = {
+ let out = ()
+ for atom in atoms {
+ if atom.at("residue") == residue {
+ out.push(atom)
+ }
+ }
+ out
+}
+
+#let _atom-point(atom) = (x: atom.at("x"), y: atom.at("y"), z: atom.at("z"))
+
+#let _anchor-point(atoms, residue, atom-kind) = {
+ let residue-atoms = _atoms-for-residue(atoms, residue)
+ if residue-atoms.len() == 0 {
+ return none
+ }
+ let ca = none
+ for atom in residue-atoms {
+ if atom.at("atom") == "CA" {
+ ca = atom
+ }
+ }
+ if _upper(str(atom-kind)) == "CA" and ca != none {
+ return _atom-point(ca)
+ }
+ let backbone = ("N", "CA", "C", "O")
+ let best = none
+ let best-distance = -1.0
+ let origin = if ca == none { residue-atoms.first() } else { ca }
+ for atom in residue-atoms {
+ if backbone.contains(atom.at("atom")) and residue-atoms.len() > backbone.len() {
+ continue
+ }
+ let distance = _vec-len2(_vec-sub(_atom-point(atom), _atom-point(origin)))
+ if distance > best-distance {
+ best = atom
+ best-distance = distance
+ }
+ }
+ if best == none { _atom-point(residue-atoms.first()) } else { _atom-point(best) }
+}
+
+#let _parse-anchor(text) = {
+ let hit = str(text).trim().matches(regex("^(-?\\d+)(?:\\[([^\\]]+)\\])?$"))
+ if hit.len() == 0 {
+ return none
+ }
+ let captures = hit.first().captures
+ (residue: int(captures.at(0)), atom: captures.at(1, default: "side"))
+}
+
+#let _parse-pdb-selection(selection) = {
+ if type(selection) == dictionary and selection.at("kind", default: none) == "pdb-selection" {
+ return selection
+ }
+ let hit = str(selection).trim().matches(regex("^(point|line|plane)(?:\\[([^\\]]+)\\])?:(.+)$"))
+ if hit.len() == 0 {
+ return none
+ }
+ let captures = hit.first().captures
+ let kind = captures.at(0)
+ let distance = if captures.at(1, default: "") == "" { 1.0 } else { float(captures.at(1)) }
+ let parts = captures.at(2).split(",").map(part => part.trim()).filter(part => part != "")
+ if parts.len() < 2 {
+ return none
+ }
+ let anchors = ()
+ for part in parts.slice(1) {
+ let anchor = _parse-anchor(part)
+ if anchor != none {
+ anchors.push(anchor)
+ }
+ }
+ (kind: "pdb-selection", shape: kind, distance: distance, source: parts.first(), anchors: anchors)
+}
+
+#let _distance2-to-segment(point, a, b) = {
+ let ab = _vec-sub(b, a)
+ let denom = _vec-len2(ab)
+ if denom == 0 {
+ return _vec-len2(_vec-sub(point, a))
+ }
+ let t = calc.max(0.0, calc.min(1.0, _vec-dot(_vec-sub(point, a), ab) / denom))
+ let projected = (x: a.at("x") + ab.at("x") * t, y: a.at("y") + ab.at("y") * t, z: a.at("z") + ab.at("z") * t)
+ _vec-len2(_vec-sub(point, projected))
+}
+
+#let _distance2-to-plane(point, a, b, c) = {
+ let normal = _vec-cross(_vec-sub(b, a), _vec-sub(c, a))
+ let denom = _vec-len2(normal)
+ if denom == 0 {
+ return _distance2-to-segment(point, a, b)
+ }
+ let numerator = _vec-dot(_vec-sub(point, a), normal)
+ numerator * numerator / denom
+}
+
+#let _residue-numbers(atoms) = {
+ let seen = (:)
+ let out = ()
+ for atom in atoms {
+ let key = str(atom.at("residue"))
+ if not seen.keys().contains(key) {
+ seen.insert(key, true)
+ out.push(atom.at("residue"))
+ }
+ }
+ out.sorted()
+}
+
+#let _pdb-selection-positions(selection) = {
+ let parsed = _parse-pdb-selection(selection)
+ if parsed == none {
+ return none
+ }
+ let atoms = _read-pdb-atoms(parsed.at("source"))
+ let anchors = ()
+ for anchor in parsed.at("anchors") {
+ let point = _anchor-point(atoms, anchor.at("residue"), anchor.at("atom"))
+ if point != none {
+ anchors.push(point)
+ }
+ }
+ let shape = parsed.at("shape", default: parsed.at("kind"))
+ let required = if shape == "point" { 1 } else if shape == "line" { 2 } else { 3 }
+ if anchors.len() < required {
+ return ()
+ }
+ let max-distance2 = parsed.at("distance") * parsed.at("distance")
+ let out = ()
+ for residue in _residue-numbers(atoms) {
+ let selected = false
+ for atom in _atoms-for-residue(atoms, residue) {
+ let point = _atom-point(atom)
+ let distance2 = if shape == "point" {
+ _vec-len2(_vec-sub(point, anchors.at(0)))
+ } else if shape == "line" {
+ _distance2-to-segment(point, anchors.at(0), anchors.at(1))
+ } else {
+ _distance2-to-plane(point, anchors.at(0), anchors.at(1), anchors.at(2))
+ }
+ if distance2 <= max-distance2 {
+ selected = true
+ }
+ }
+ if selected {
+ out.push(residue)
+ }
+ }
+ out
+}
+
+#let pdb-selection-list(selection) = {
+ let positions = _pdb-selection-positions(selection)
+ if positions == none {
+ return str(selection)
+ }
+ positions.map(pos => str(pos)).join(",")
+}
diff --git a/packages/preview/typshade/0.1.3/internal/model/text-style.typ b/packages/preview/typshade/0.1.3/internal/model/text-style.typ
new file mode 100644
index 0000000000..806f2b51e2
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/model/text-style.typ
@@ -0,0 +1,132 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "parser.typ": _upper
+
+#let _text-style-targets = (
+ "residues",
+ "numbering",
+ "names",
+ "feature-text-labels",
+ "feature-style-labels",
+ "features",
+ "feature-styles",
+ "legend",
+ "ruler",
+ "ruler-label",
+)
+
+#let _default-text-styles = (
+ "residues": (family: "mono", weight: "regular", style: "normal", size: "normal"),
+ "numbering": (family: "mono", weight: "regular", style: "normal", size: "normal"),
+ "names": (family: "mono", weight: "regular", style: "normal", size: "normal"),
+ "feature-text-labels": (family: "mono", weight: "regular", style: "normal", size: "normal"),
+ "feature-style-labels": (family: "mono", weight: "regular", style: "normal", size: "normal"),
+ "features": (family: "serif", weight: "regular", style: "italic", size: "normal"),
+ "feature-styles": (family: "mono", weight: "regular", style: "normal", size: "normal"),
+ "legend": (family: "mono", weight: "regular", style: "normal", size: "normal"),
+ "ruler": (family: "sans", weight: "regular", style: "normal", size: "normal"),
+ "ruler-label": (family: "sans", weight: "regular", style: "normal", size: "normal"),
+)
+
+#let _font-family(config, family) = {
+ if family == "mono" {
+ return config.at("font")
+ }
+ if family == "sans" {
+ return config.at("font-families").at("sans")
+ }
+ if family == "serif" {
+ return config.at("font-families").at("serif")
+ }
+ family
+}
+
+#let _size-factor(size) = {
+ if type(size) != str {
+ return size
+ }
+ if size == "tiny" {
+ 0.55
+ } else if size == "x-small" {
+ 0.7
+ } else if size == "smaller" {
+ 0.8
+ } else if size == "small" {
+ 0.9
+ } else if size == "normal" {
+ 1.0
+ } else if size == "large" {
+ 1.2
+ } else if size == "x-large" {
+ 1.44
+ } else if size == "xx-large" {
+ 1.72
+ } else if size == "huge" {
+ 2.05
+ } else if size == "x-huge" {
+ 2.45
+ } else {
+ 1.0
+ }
+}
+
+#let _font-size(config, size) = {
+ if type(size) == str {
+ config.at("font-size") * _size-factor(size)
+ } else {
+ size
+ }
+}
+
+#let _target-list(target) = if target == "all" {
+ _text-style-targets
+} else if type(target) == str {
+ (target,)
+} else {
+ target
+}
+
+#let _set-text-style(config, target, key, value) = {
+ for name in _target-list(target) {
+ if config.at("text-styles").keys().contains(name) {
+ config.at("text-styles").at(name).insert(key, value)
+ }
+ }
+}
+
+#let _text-style(config, target) = {
+ let base = _default-text-styles.at(target, default: _default-text-styles.at("residues"))
+ let overrides = config.at("text-styles").at(target, default: (:))
+ let family = overrides.at("family", default: base.at("family"))
+ let weight = overrides.at("weight", default: base.at("weight"))
+ let style = overrides.at("style", default: base.at("style"))
+ let size = overrides.at("size", default: base.at("size"))
+ (
+ font: _font-family(config, family),
+ size: _font-size(config, size),
+ weight: if weight == "bold" { "bold" } else { "regular" },
+ style: if style == "italic" or style == "oblique" { "italic" } else { "normal" },
+ smallcaps: style == "small-caps",
+ )
+}
+
+#let _text-params(config, target, fill: black, style: auto, size: auto) = {
+ let resolved = _text-style(config, target)
+ (
+ font: resolved.at("font"),
+ size: if size == auto { resolved.at("size") } else { size },
+ weight: resolved.at("weight"),
+ style: if style == auto { resolved.at("style") } else { style },
+ fill: fill,
+ )
+}
+
+#let _text-string(config, target, value) = {
+ let text = str(value)
+ if _text-style(config, target).at("smallcaps") {
+ _upper(text)
+ } else {
+ text
+ }
+}
diff --git a/packages/preview/typshade/0.1.3/internal/render/alignment.typ b/packages/preview/typshade/0.1.3/internal/render/alignment.typ
new file mode 100644
index 0000000000..1b261ba1a0
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/render/alignment.typ
@@ -0,0 +1,1280 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../engine/config.typ": _apply-command, _default-config
+#import "features.typ": _bottom-feature-slots, _feature-rows, _ruler-rows, _top-feature-slots
+#import "../engine/layout.typ": _empty-cell, _selection-columns, _sorted-unique
+#import "../model/logo.typ": _resolve-sequence
+#import "logos.typ": _legend-block, _logo-block
+#import "../model/palette.typ": resolve-color, scale-color
+#import "../model/parser.typ": _array-fill, _chars, _lower, _upper, read-alignment
+#import "../model/text-style.typ": _font-size, _text-params, _text-string
+
+#let _display-columns(alignment, config) = {
+ let selected = ()
+ for command in config.at("sequence-windows") {
+ let seq = alignment.at("sequences").at(_resolve-sequence(alignment, command.at("sequence")))
+ for col in _selection-columns(seq, command.at("selection"), alignment: alignment) {
+ selected.push(col)
+ }
+ }
+ if selected.len() == 0 {
+ selected = range(0, alignment.at("columns"))
+ }
+ let columns = _sorted-unique(selected)
+ if not config.at("hide-allmatch-positions") {
+ return columns
+ }
+ let out = ()
+ for col in columns {
+ let info = _style-for-column(alignment, config, col)
+ if info.at("consensus-score") < config.at("allmatch-threshold") {
+ out.push(col)
+ }
+ }
+ out
+}
+
+#let _residue-group(char, seq-type, config) = {
+ let groups = if seq-type == "N" { config.at("dna-groups") } else { config.at("pep-groups") }
+ for (idx, group) in groups.enumerate() {
+ if group.contains(char) {
+ return idx
+ }
+ }
+ none
+}
+
+#let _is-similar(residue, consensus, seq-type, config) = {
+ if residue == consensus {
+ return true
+ }
+ let sims = if seq-type == "N" { config.at("dna-sims") } else { config.at("pep-sims") }
+ if sims.keys().contains(consensus) {
+ return sims.at(consensus).contains(residue)
+ }
+ false
+}
+
+#let _weight-order = ("C", "S", "T", "P", "A", "G", "N", "D", "E", "Q", "H", "R", "K", "M", "I", "L", "V", "F", "Y", "W")
+
+#let _weight-index(value) = {
+ for (idx, item) in _weight-order.enumerate() {
+ if item == value {
+ return idx
+ }
+ }
+ none
+}
+
+#let _structural-rows = (
+ C: (10, 6, 3, 3, 3, 5, 3, 1, 0, 1, 3, 3, 0, 3, 3, 3, 3, 5, 5, 5),
+ S: (6, 10, 8, 6, 8, 8, 8, 6, 5, 5, 5, 5, 5, 3, 3, 3, 6, 5, 5, 3),
+ T: (3, 8, 10, 6, 8, 6, 6, 5, 5, 5, 3, 5, 6, 5, 5, 3, 6, 3, 3, 1),
+ P: (3, 6, 6, 10, 8, 6, 3, 5, 5, 5, 5, 5, 3, 3, 3, 5, 6, 5, 3, 3),
+ A: (3, 8, 8, 8, 10, 8, 5, 6, 6, 5, 3, 3, 5, 5, 3, 3, 8, 5, 3, 3),
+ G: (5, 8, 6, 6, 8, 10, 5, 6, 6, 3, 1, 5, 3, 1, 3, 3, 6, 3, 3, 5),
+ N: (3, 8, 6, 3, 5, 5, 10, 8, 6, 5, 6, 5, 6, 1, 3, 1, 3, 3, 5, 0),
+ D: (1, 6, 5, 5, 6, 6, 8, 10, 8, 6, 5, 3, 5, 3, 1, 1, 5, 1, 3, 0),
+ E: (0, 5, 5, 5, 6, 6, 6, 8, 10, 6, 3, 5, 6, 3, 1, 1, 6, 3, 1, 1),
+ Q: (1, 5, 5, 5, 5, 3, 5, 6, 6, 10, 6, 5, 6, 3, 1, 3, 3, 1, 3, 1),
+ H: (3, 5, 3, 5, 3, 1, 6, 5, 3, 6, 10, 6, 5, 3, 3, 5, 1, 3, 5, 1),
+ R: (3, 5, 5, 5, 3, 5, 5, 3, 5, 5, 6, 10, 8, 3, 3, 3, 3, 1, 1, 3),
+ K: (0, 5, 6, 3, 5, 3, 6, 5, 6, 6, 5, 8, 10, 3, 3, 3, 5, 1, 1, 1),
+ M: (3, 5, 5, 3, 5, 1, 1, 3, 3, 3, 3, 3, 3, 10, 6, 8, 6, 5, 3, 5),
+ I: (3, 3, 5, 3, 3, 3, 3, 1, 1, 1, 3, 3, 3, 6, 10, 8, 3, 6, 5, 5),
+ L: (3, 3, 3, 5, 3, 3, 1, 1, 1, 3, 5, 3, 3, 8, 8, 10, 3, 6, 5, 6),
+ V: (3, 6, 6, 6, 8, 6, 3, 5, 6, 3, 1, 3, 5, 6, 3, 3, 10, 6, 5, 5),
+ F: (5, 5, 3, 5, 5, 3, 3, 1, 3, 1, 3, 1, 1, 5, 6, 6, 6, 10, 8, 5),
+ Y: (5, 5, 3, 3, 3, 3, 5, 3, 1, 3, 5, 1, 1, 3, 5, 5, 5, 8, 10, 5),
+ W: (5, 3, 1, 3, 3, 5, 0, 0, 1, 1, 1, 3, 1, 5, 5, 6, 5, 5, 5, 10),
+)
+
+#let _pam250-rows = (
+ C: (4, 0, -2, -3, -2, -3, -4, 2, -5, -5, -3, -4, -5, -5, -2, -6, -2, -4, 0, -8),
+ S: (0, 3, 1, 1, 1, 1, 1, 0, 0, -1, -1, 0, 0, -2, -1, -3, -1, -3, -3, -2),
+ T: (-2, 1, 3, 0, 1, 0, 0, 0, 0, -1, -1, -1, 0, -1, 0, -2, 0, -2, -3, -5),
+ P: (-3, 1, 0, 6, 1, -1, -1, -1, -1, 0, 0, 0, -1, -2, -2, -3, -1, -5, -5, -6),
+ A: (-2, 1, 1, 1, 2, 1, 0, 0, 0, 0, -1, -2, -1, -1, -1, -2, 0, -4, -3, -6),
+ G: (-3, 1, 0, -1, 1, 5, 0, 1, 0, -1, -2, -3, -2, -3, -3, -4, -1, -5, -5, -7),
+ N: (-4, 1, 0, -1, 0, 0, 2, 2, 1, 1, 2, 0, 1, -2, -2, -3, -2, -4, -2, -4),
+ D: (-5, 0, 0, -1, 0, 1, 2, 4, 3, 2, 1, -1, 0, -3, -2, -4, -2, -6, -4, -7),
+ E: (-5, 0, 0, -1, 0, 0, 1, 3, 4, 2, 1, -1, 0, -2, -2, -3, -2, -5, -4, -7),
+ Q: (-5, -1, -1, 0, 0, -1, 1, 2, 2, 4, 3, 1, 1, -1, -2, -2, -2, -5, -4, -5),
+ H: (-3, -1, -1, 0, -1, -2, 2, 1, 1, 3, 6, 2, 0, -2, -2, -2, -2, -2, -5, -7),
+ R: (-4, 0, -1, 0, -2, -3, 0, -1, -1, 1, 2, 6, 3, 0, -2, -3, -2, -4, -4, 2),
+ K: (-5, 0, 0, -1, -1, -2, 1, 0, 0, 1, 0, 3, 5, 0, -2, -3, -2, -5, -4, -3),
+ M: (-5, -2, -1, -2, -1, -3, -2, -3, -2, -1, -2, 0, 0, 6, 2, 4, 2, 0, -2, -4),
+ I: (-2, -1, 0, -2, -1, -3, -2, -2, -2, -2, -2, -2, -2, 2, 5, 2, 4, 1, -1, -5),
+ L: (-6, -3, -2, -3, -2, -4, -3, -4, -3, -2, -2, -3, -3, 4, 2, 6, 2, 2, -1, -2),
+ V: (-2, -1, 0, -1, 0, -1, -2, -2, -2, -2, -2, -2, -2, 2, 4, 2, 4, -1, -2, -6),
+ F: (-4, -3, -2, -5, -4, -5, -4, -6, -5, -5, -2, -4, -5, 0, 1, 2, -1, 9, 7, 0),
+ Y: (0, -3, -3, -5, -3, -5, -2, -4, -4, -4, 0, -4, -4, -2, -1, -1, -2, 7, 10, 0),
+ W: (-8, -2, -5, -6, -6, -7, -4, -7, -7, -5, -3, 2, -3, -4, -5, -2, -6, 0, 0, 17),
+)
+
+#let _pam100-rows = (
+ C: (14, -1, -5, -6, -5, -8, -8, -11, -11, -11, -6, -6, -11, -11, -5, -12, -4, -10, -2, -13),
+ S: (-1, 6, 2, 1, 2, 1, 2, -1, -2, -3, -4, -1, -2, -4, -4, -7, -4, -5, -6, -4),
+ T: (-5, 2, 7, -1, 2, -3, 0, -2, -3, -3, -5, -4, -1, -2, -1, -5, -1, -6, -6, -10),
+ P: (-6, 1, -1, 10, 1, -3, -3, -4, -3, -1, -2, -2, -4, -6, -6, -5, -4, -9, -11, -11),
+ A: (-5, 2, 2, 1, 6, 1, -1, -1, 0, -2, -5, -5, -4, -3, -3, -5, 0, -7, -6, -11),
+ G: (-8, 1, -3, -3, 1, 8, -1, -1, -2, -5, -7, -8, -5, -8, -7, -8, -4, -8, -11, -13),
+ N: (-8, 2, 0, -3, -1, -1, 7, 4, 1, -1, 2, -3, 1, -5, -4, -6, -5, -6, -3, -8),
+ D: (-11, -1, -2, -4, -1, -1, 4, 8, 5, 1, -1, -6, -2, -8, -6, -9, -6, -11, -9, -13),
+ E: (-11, -2, -3, -3, 0, -2, 1, 5, 8, 4, -2, -5, -2, -6, -5, -7, -5, -11, -7, -14),
+ Q: (-11, -3, -3, -1, -2, -5, -1, 1, 4, 9, 4, 1, -1, -2, -5, -3, -5, -10, -9, -11),
+ H: (-6, -4, -5, -2, -5, -7, 2, -1, -2, 4, 11, 1, -3, -7, -7, -5, -6, -4, -1, -7),
+ R: (-6, -1, -4, -2, -5, -8, -3, -6, -5, 1, 1, 10, 3, -2, -4, -7, -6, -7, -10, 1),
+ K: (-11, -2, -1, -4, -4, -5, 1, -2, -2, -1, -3, 3, 8, 1, -4, -6, -6, -11, -10, -9),
+ M: (-11, -4, -2, -6, -3, -8, -5, -8, -6, -2, -7, -2, 1, 13, 2, 4, 1, -2, -8, -11),
+ I: (-5, -4, -1, -6, -3, -7, -4, -6, -5, -5, -7, -4, -4, 2, 9, 2, 5, 0, -4, -12),
+ L: (-12, -7, -5, -5, -5, -8, -6, -9, -7, -3, -5, -7, -6, 4, 2, 9, 1, 0, -5, -7),
+ V: (-4, -4, -1, -4, 0, -4, -5, -6, -5, -5, -6, -6, -6, 1, 5, 1, 8, -5, -6, -14),
+ F: (-10, -5, -6, -9, -7, -8, -6, -11, -11, -10, -4, -7, -11, -2, 0, 0, -5, 12, 6, -2),
+ Y: (-2, -6, -6, -11, -6, -11, -3, -9, -7, -9, -1, -10, -10, -8, -4, -5, -6, 6, 13, -2),
+ W: (-13, -4, -10, -11, -11, -13, -8, -13, -14, -11, -7, 1, -9, -11, -12, -7, -14, -2, -2, 19),
+)
+
+#let _blosum62-rows = (
+ C: (9, -1, -1, -3, 0, -3, -3, -3, -4, -3, -3, -3, -3, -1, -1, -1, -1, -2, -2, -2),
+ S: (-1, 4, 1, -1, 1, 0, 1, 0, 0, 0, -1, -1, 0, -1, -2, -2, -2, -2, -2, -3),
+ T: (-1, 1, 4, 1, -1, 1, 0, 1, 0, 0, 0, -1, 0, -1, -2, -2, -2, -2, -2, -3),
+ P: (-3, -1, 1, 7, -1, -2, -1, -1, -1, -1, -2, -2, -1, -2, -3, -3, -2, -4, -3, -4),
+ A: (0, 1, -1, -1, 4, 0, -1, -2, -1, -1, -2, -1, -1, -1, -1, -1, -2, -2, -2, -3),
+ G: (-3, 0, 1, -2, 0, 6, -2, -1, -2, -2, -2, -2, -2, -3, -4, -4, 0, -3, -3, -2),
+ N: (-3, 1, 0, -2, -2, 0, 6, 1, 0, 0, -1, 0, 0, -2, -3, -3, -3, -3, -2, -4),
+ D: (-3, 0, 1, -1, -2, -1, 1, 6, 2, 0, -1, -2, -1, -3, -3, -4, -3, -3, -3, -4),
+ E: (-4, 0, 0, -1, -1, -2, 0, 2, 5, 2, 0, 0, 1, -2, -3, -3, -3, -3, -2, -3),
+ Q: (-3, 0, 0, -1, -1, -2, 0, 0, 2, 5, 0, 1, 1, 0, -3, -2, -2, -3, -1, -2),
+ H: (-3, -1, 0, -2, -2, -2, 1, 1, 0, 0, 8, 0, -1, -2, -3, -3, -2, -1, 2, -2),
+ R: (-3, -1, -1, -2, -1, -2, 0, -2, 0, 1, 0, 5, 2, -1, -3, -2, -3, -3, -2, -3),
+ K: (-3, 0, 0, -1, -1, -2, 0, -1, 1, 1, -1, 2, 5, -1, -3, -2, -3, -3, -2, -3),
+ M: (-1, -1, -1, -2, -1, -3, -2, -3, -2, 0, -2, -1, -1, 5, 1, 2, -2, 0, -1, -1),
+ I: (-1, -2, -2, -3, -1, -4, -3, -3, -3, -3, -3, -3, -3, 1, 4, 2, 1, 0, -1, -3),
+ L: (-1, -2, -2, -3, -1, -4, -3, -4, -3, -2, -3, -2, -2, 2, 2, 4, 3, 0, -1, -2),
+ V: (-1, -2, -2, -2, 0, -3, -3, -3, -2, -2, -3, -3, -2, 1, 3, 1, 4, -1, -1, -3),
+ F: (-2, -2, -2, -4, -2, -3, -3, -3, -3, -3, -1, -3, -3, 0, 0, 0, -1, 6, 3, 1),
+ Y: (-2, -2, -2, -3, -2, -3, -2, -3, -2, -1, 2, -2, -2, -1, -1, -1, -1, 3, 7, 2),
+ W: (-2, -3, -3, -4, -3, -2, -4, -4, -3, -2, -2, -3, -3, -1, -3, -2, -3, 1, 2, 11),
+)
+
+#let _matrix-score(rows, a, b) = {
+ let row = rows.at(a, default: none)
+ let index = _weight-index(b)
+ if row == none or index == none {
+ none
+ } else {
+ row.at(index)
+ }
+}
+
+#let _residue-weight(candidate, residue, seq-type, config) = {
+ let pair = _upper(candidate) + ":" + _upper(residue)
+ let custom = config.at("custom-weights")
+ if custom.keys().contains(pair) {
+ return custom.at(pair)
+ }
+ if str(config.at("weight-table")) == "structural" {
+ let score = _matrix-score(_structural-rows, _upper(candidate), _upper(residue))
+ if score != none {
+ return score
+ }
+ }
+ if str(config.at("weight-table")) == "BLOSUM62" {
+ let score = _matrix-score(_blosum62-rows, _upper(candidate), _upper(residue))
+ if score != none {
+ return score
+ }
+ }
+ if str(config.at("weight-table")) == "PAM250" {
+ let score = _matrix-score(_pam250-rows, _upper(candidate), _upper(residue))
+ if score != none {
+ return score
+ }
+ }
+ if str(config.at("weight-table")) == "PAM100" {
+ let score = _matrix-score(_pam100-rows, _upper(candidate), _upper(residue))
+ if score != none {
+ return score
+ }
+ }
+ if candidate == residue {
+ return 10
+ }
+ let table = str(config.at("weight-table"))
+ if table == "identity" {
+ return 0
+ }
+ if _is-similar(candidate, residue, seq-type, config) {
+ return if table == "structural" { 6 } else { 4 }
+ }
+ if _residue-group(candidate, seq-type, config) == _residue-group(residue, seq-type, config) {
+ return if table == "structural" { 3 } else { 1 }
+ }
+ if table == "PAM250" or table == "PAM100" or table == "BLOSUM62" {
+ return -2
+ }
+ 0
+}
+
+#let _weighted-consensus(counts, residues, seq-type, config) = {
+ if counts.keys().len() == 0 {
+ return (consensus: none, score: 0)
+ }
+ let best = counts.keys().first()
+ let best-score = none
+ let best-count = 0
+ for candidate in counts.keys() {
+ let score = 0
+ for residue in residues {
+ let key = _upper(residue)
+ if key == "." or key == "-" or key == "" {
+ score += config.at("gap-penalty")
+ } else {
+ score += _residue-weight(candidate, key, seq-type, config)
+ }
+ }
+ if best-score == none or score > best-score or (score == best-score and counts.at(candidate) > best-count) {
+ best = candidate
+ best-score = score
+ best-count = counts.at(candidate)
+ }
+ }
+ let max-self = calc.max(1, _residue-weight(best, best, seq-type, config))
+ let normalized = calc.max(0, calc.min(100, best-score / (max-self * residues.len()) * 100))
+ (consensus: best, score: normalized)
+}
+
+#let _matches-sequence(alignment, refs, index) = {
+ let seq = alignment.at("sequences").at(index)
+ for ref in refs {
+ if ref == index + 1 or str(ref) == str(index + 1) or str(ref) == seq.at("name") {
+ return true
+ }
+ }
+ false
+}
+
+#let _validate-sequence-ref-list(alignment, refs) = {
+ for ref in refs {
+ let _ = _resolve-sequence(alignment, ref)
+ }
+ none
+}
+
+#let _validate-sequence-ref-map(alignment, refs) = {
+ for ref in refs.keys() {
+ let _ = _resolve-sequence(alignment, ref)
+ }
+ none
+}
+
+#let _validate-config-sequence-refs(alignment, config) = {
+ _validate-sequence-ref-list(alignment, config.at("hidden-names"))
+ _validate-sequence-ref-list(alignment, config.at("hidden-numbers"))
+ _validate-sequence-ref-list(alignment, config.at("hidden"))
+ _validate-sequence-ref-list(alignment, config.at("killed"))
+ _validate-sequence-ref-list(alignment, config.at("no-shade"))
+ _validate-sequence-ref-list(alignment, config.at("separation-lines"))
+ _validate-sequence-ref-list(alignment, config.at("subfamily"))
+ _validate-sequence-ref-map(alignment, config.at("sequence-names"))
+ _validate-sequence-ref-map(alignment, config.at("sequence-name-colors"))
+ _validate-sequence-ref-map(alignment, config.at("sequence-number-colors"))
+ _validate-sequence-ref-map(alignment, config.at("start-numbers"))
+ _validate-sequence-ref-map(alignment, config.at("sequence-lengths"))
+ none
+}
+
+#let _sequence-label(alignment, config, sequence, sequence-index) = {
+ let by-index = config.at("sequence-names").at(str(sequence-index + 1), default: none)
+ if by-index != none {
+ return by-index
+ }
+ config.at("sequence-names").at(sequence.at("name"), default: sequence.at("name"))
+}
+
+#let _sequence-color(config, sequence, sequence-index, key, default-color) = {
+ let colors = config.at(key)
+ let by-index = colors.at(str(sequence-index + 1), default: none)
+ if by-index != none {
+ return by-index
+ }
+ colors.at(sequence.at("name"), default: default-color)
+}
+
+#let _config-ref-value(map, sequence, sequence-index, default: none) = {
+ let by-index = map.at(str(sequence-index + 1), default: none)
+ if by-index != none {
+ return by-index
+ }
+ map.at(sequence.at("name"), default: default)
+}
+
+#let _renumber-position(raw-pos, start, allow-zero) = {
+ if raw-pos == none {
+ return none
+ }
+ let shifted = start + raw-pos - 1
+ if not allow-zero and start < 1 and shifted >= 0 {
+ shifted + 1
+ } else {
+ shifted
+ }
+}
+
+#let _rebuild-sequence(sequence, aligned) = {
+ let positions = ()
+ let raw = ""
+ let pos = 0
+ for ch in _chars(aligned) {
+ if ch == "." or ch == "-" {
+ positions.push(none)
+ } else {
+ pos += 1
+ raw += ch
+ positions.push(pos)
+ }
+ }
+ sequence.insert("aligned", aligned)
+ sequence.insert("raw", raw)
+ sequence.insert("positions", positions)
+ sequence.insert("length", pos)
+}
+
+#let _apply-single-sequence-options(alignment, config) = {
+ if alignment.at("sequences").len() != 1 or config.at("single-seq-shift") == none {
+ return
+ }
+ let sequence = alignment.at("sequences").first()
+ let aligned = sequence.at("aligned")
+ if not config.at("keep-single-seq-gaps") {
+ while aligned.starts-with(".") or aligned.starts-with("-") {
+ aligned = aligned.slice(1)
+ if aligned.len() == 0 {
+ break
+ }
+ }
+ }
+ let shift = int(config.at("single-seq-shift"))
+ if shift > 0 {
+ aligned = "." * shift + aligned
+ } else if shift < 0 {
+ let remove = calc.min(-shift, aligned.len())
+ aligned = aligned.slice(remove)
+ }
+ _rebuild-sequence(sequence, aligned)
+ alignment.insert("columns", aligned.len())
+}
+
+#let _apply-numbering-overrides(alignment, config) = {
+ for (idx, sequence) in alignment.at("sequences").enumerate() {
+ let start = _config-ref-value(config.at("start-numbers"), sequence, idx, default: 1)
+ if start != 1 {
+ let positions = ()
+ for pos in sequence.at("positions") {
+ positions.push(_renumber-position(pos, start, config.at("allow-zero")))
+ }
+ sequence.insert("positions", positions)
+ }
+ let length = _config-ref-value(config.at("sequence-lengths"), sequence, idx, default: none)
+ if length != none {
+ sequence.insert("length", length)
+ }
+ }
+}
+
+#let _functional-mode(config) = {
+ let option = config.at("shading").at("option", default: none)
+ if option == none { config.at("functional-default") } else { option }
+}
+
+#let _functional-style(config, residue) = {
+ let overrides = config.at("functional-style-overrides")
+ if overrides.keys().contains(residue) {
+ return overrides.at(residue)
+ }
+ let mode = _functional-mode(config)
+ let groups = config.at("functional-groups")
+ if not groups.keys().contains(mode) {
+ return none
+ }
+ for group in groups.at(mode) {
+ if group.at("residues").contains(residue) {
+ return group
+ }
+ }
+ none
+}
+
+#let _tint-color(effect) = {
+ if effect == "weak" {
+ "Gray10"
+ } else if effect == "strong" {
+ "Gray30"
+ } else {
+ "LightGray"
+ }
+}
+
+#let _column-tcoffee-score(alignment, config, col) = {
+ let total = 0
+ let count = 0
+ for seq in alignment.at("sequences") {
+ let scores = config.at("tcoffee").at("scores").at(seq.at("name"), default: ())
+ if col < scores.len() and scores.at(col) != none {
+ total += scores.at(col)
+ count += 1
+ }
+ }
+ if count == 0 { 0 } else { total / count }
+}
+
+#let _style-for-column(alignment, config, col) = {
+ let mode = config.at("shading").at("mode")
+ let seq-type = alignment.at("seq-type")
+ let residues = ()
+ let counts = (:)
+ for seq in alignment.at("sequences") {
+ let char = seq.at("aligned").slice(col, col + 1)
+ residues.push(char)
+ if char != "." and char != "-" {
+ let key = _upper(char)
+ counts.insert(key, counts.at(key, default: 0) + 1)
+ }
+ }
+ let styles = ()
+ let consensus = none
+ let consensus-score = 0
+ let consensus-forced = false
+ if counts.keys().len() > 0 {
+ let max-key = counts.keys().first()
+ let max-count = counts.at(max-key)
+ for key in counts.keys() {
+ if counts.at(key) > max-count {
+ max-key = key
+ max-count = counts.at(key)
+ }
+ }
+ consensus = max-key
+ consensus-score = max-count / alignment.at("sequences").len() * 100
+ }
+ let consensus-source = config.at("consensus").at("source", default: "all")
+ if consensus-source == "all" and (config.at("weight-table") != "identity" or config.at("custom-weights").keys().len() > 0 or config.at("gap-penalty") != 0) {
+ let weighted = _weighted-consensus(counts, residues, seq-type, config)
+ consensus = weighted.at("consensus")
+ consensus-score = weighted.at("score")
+ }
+ if consensus-source != "all" {
+ let source-index = _resolve-sequence(alignment, consensus-source)
+ let source-char = _upper(alignment.at("sequences").at(source-index).at("aligned").slice(col, col + 1))
+ if source-char == "." or source-char == "-" or source-char == "" {
+ consensus = none
+ consensus-score = 0
+ } else {
+ consensus = source-char
+ consensus-score = counts.at(source-char, default: 0) / alignment.at("sequences").len() * 100
+ consensus-forced = true
+ }
+ }
+ let group-majority = none
+ let group-counts = (:)
+ for key in counts.keys() {
+ let group = _residue-group(key, seq-type, config)
+ if group != none {
+ let group-key = str(group)
+ group-counts.insert(group-key, group-counts.at(group-key, default: 0) + counts.at(key))
+ }
+ }
+ let max-group-count = 0
+ for key in group-counts.keys() {
+ if group-counts.at(key) > max-group-count {
+ max-group-count = group-counts.at(key)
+ group-majority = int(key)
+ }
+ }
+ let reference-index = if config.at("shading").at("reference") != none { _resolve-sequence(alignment, config.at("shading").at("reference")) } else { 0 }
+ let reference-char = alignment.at("sequences").at(reference-index).at("aligned").slice(col, col + 1)
+ let styled = (kind, char) => {
+ let style = config.at("residue-style").at(kind)
+ let token = style.at("case", default: "upper")
+ let source-char = if char == "*" { config.at("stop-char") } else { char }
+ let out-char = if token == "upper" {
+ _upper(source-char)
+ } else if token == "lower" {
+ _lower(source-char)
+ } else if token == "" or token == "normal" {
+ source-char
+ } else {
+ token
+ }
+ (
+ kind: kind,
+ char: out-char,
+ fg: style.at("fg"),
+ bg: style.at("bg"),
+ emph: style.at("style", default: "normal") == "italic" or style.at("style", default: "normal") == "oblique",
+ )
+ }
+ for (res-index, char) in residues.enumerate() {
+ if char == "." or char == "-" {
+ styles.push((kind: "gap", char: config.at("gaps").at("char"), fg: config.at("gaps").at("fg"), bg: config.at("gaps").at("bg"), rule: config.at("gaps").at("char") == "rule"))
+ } else if mode == "T-Coffee" {
+ let seq-name = alignment.at("sequences").at(res-index).at("name")
+ let seq-scores = config.at("tcoffee").at("scores").at(seq-name, default: ())
+ let score = if col < seq-scores.len() and seq-scores.at(col) != none { seq-scores.at(col) } else { 0 }
+ styles.push((kind: "tcoffee", char: char, fg: "Black", bg: scale-color("TCoffee", score), score: score))
+ } else if mode == "functional" {
+ let group = _functional-style(config, _upper(char))
+ if group == none {
+ styles.push(styled("nomatch", char))
+ } else {
+ let styled-char = styled("nomatch", char).at("char")
+ let token = group.at("case", default: "upper")
+ let out-char = if token == "upper" {
+ _upper(char)
+ } else if token == "lower" {
+ _lower(char)
+ } else if token == "" or token == "normal" {
+ char
+ } else {
+ token
+ }
+ styles.push((
+ kind: "functional",
+ char: if group.keys().contains("case") { out-char } else { styled-char },
+ fg: group.at("fg"),
+ bg: group.at("bg"),
+ emph: group.at("style", default: "normal") == "italic" or group.at("style", default: "normal") == "oblique",
+ ))
+ }
+ } else if mode == "diverse" {
+ if char == reference-char {
+ styles.push((kind: "allmatch", char: ".", fg: "Black", bg: "White"))
+ } else if _is-similar(_upper(char), _upper(reference-char), seq-type, config) {
+ styles.push((kind: "conserved", char: ".", fg: "Black", bg: "White"))
+ } else {
+ styles.push((kind: "nomatch", char: _lower(char), fg: "Black", bg: "White"))
+ }
+ } else if mode == "similar" {
+ if consensus != none and consensus-score >= config.at("allmatch-threshold") {
+ styles.push(styled("allmatch", char))
+ } else if consensus != none and consensus-score >= config.at("threshold") and char == consensus {
+ styles.push(styled("conserved", char))
+ } else if group-majority != none and max-group-count / alignment.at("sequences").len() * 100 >= config.at("threshold") and _residue-group(_upper(char), seq-type, config) == group-majority {
+ styles.push(styled("similar", char))
+ } else {
+ styles.push(styled("nomatch", char))
+ }
+ } else if mode == "singleseq" {
+ styles.push(styled("nomatch", char))
+ } else {
+ if consensus != none and consensus-score >= config.at("allmatch-threshold") {
+ styles.push(styled("allmatch", char))
+ } else if consensus != none and consensus-score >= config.at("threshold") and char == consensus {
+ styles.push(styled("conserved", char))
+ } else {
+ styles.push(styled("nomatch", char))
+ }
+ }
+ }
+ (styles: styles, consensus: consensus, consensus-score: consensus-score, consensus-forced: consensus-forced)
+}
+
+#let _base-cell(char, fg: "Black", bg: "White", lower: false, emph: false, frame: none) = (
+ char: if lower { _lower(char) } else { char },
+ fg: fg,
+ bg: bg,
+ emph: emph,
+ frame: frame,
+)
+
+#let _merge-cell-delta(cell, delta) = {
+ if delta == none {
+ return cell
+ }
+ assert(type(delta) == dictionary, message: "typshade: cell-style callbacks must return none or a dictionary")
+ let updated = cell
+ for key in ("char", "fg", "bg", "emph", "frame", "frame-thickness", "rule") {
+ if delta.keys().contains(key) {
+ updated.insert(key, delta.at(key))
+ }
+ }
+ if delta.at("case", default: none) == "upper" {
+ updated.insert("char", _upper(updated.at("char")))
+ } else if delta.at("case", default: none) == "lower" {
+ updated.insert("char", _lower(updated.at("char")))
+ }
+ if delta.at("lower", default: false) {
+ updated.insert("char", _lower(updated.at("char")))
+ }
+ updated
+}
+
+#let _apply-cell-styles(alignment, config, sequence, sequence-index, column, column-info, style-info, cell) = {
+ let updated = cell
+ if config.at("cell-styles").len() == 0 {
+ return updated
+ }
+ let cell-context = (
+ alignment: alignment,
+ sequence: sequence,
+ sequence-index: sequence-index,
+ sequence-number: sequence-index + 1,
+ sequence-name: sequence.at("name"),
+ column-index: column,
+ column: column + 1,
+ position: sequence.at("positions").at(column),
+ residue: sequence.at("aligned").slice(column, column + 1),
+ kind: style-info.at("kind", default: "custom"),
+ consensus: column-info.at("consensus"),
+ consensus-score: column-info.at("consensus-score"),
+ consensus-forced: column-info.at("consensus-forced", default: false),
+ cell: updated,
+ )
+ for callback in config.at("cell-styles") {
+ updated = _merge-cell-delta(updated, callback(cell-context))
+ cell-context.insert("cell", updated)
+ }
+ updated
+}
+
+#let _apply-regions(alignment, config, sequence-index, column, cell) = {
+ let updated = cell
+ for region in config.at("shade-regions") {
+ let target = _resolve-sequence(alignment, region.at("sequence"))
+ if target == sequence-index or region.at("all") {
+ let seq = alignment.at("sequences").at(target)
+ if _selection-columns(seq, region.at("selection"), alignment: alignment).contains(column) {
+ let explicit-bg = region.at("bg", default: none)
+ let scheme = region.at("scheme", default: config.at("shading").at("scheme"))
+ let fill = if explicit-bg != none { explicit-bg } else if scheme == "reds" { "BrickRed" } else if scheme == "greens" { "PineGreen" } else if scheme == "grays" { "DarkGray" } else { "RoyalBlue" }
+ updated.insert("bg", fill)
+ updated.insert("fg", if region.at("fg", default: none) != none { region.at("fg") } else if fill == "DarkGray" or fill == "PineGreen" or fill == "RoyalBlue" { "White" } else { updated.at("fg") })
+ }
+ }
+ }
+ for region in config.at("tint-regions") {
+ let target = _resolve-sequence(alignment, region.at("sequence"))
+ if target == sequence-index {
+ let seq = alignment.at("sequences").at(target)
+ if _selection-columns(seq, region.at("selection"), alignment: alignment).contains(column) {
+ let effect = if region.at("intensity", default: "medium") == "medium" { config.at("tint-default") } else { region.at("intensity") }
+ updated.insert("bg", _tint-color(effect))
+ }
+ }
+ }
+ for region in config.at("lower-regions") {
+ let target = _resolve-sequence(alignment, region.at("sequence"))
+ if target == sequence-index {
+ let seq = alignment.at("sequences").at(target)
+ if _selection-columns(seq, region.at("selection"), alignment: alignment).contains(column) {
+ updated.insert("char", _lower(updated.at("char")))
+ }
+ }
+ }
+ for region in config.at("emph-regions") {
+ let target = _resolve-sequence(alignment, region.at("sequence"))
+ if target == sequence-index {
+ let seq = alignment.at("sequences").at(target)
+ if _selection-columns(seq, region.at("selection"), alignment: alignment).contains(column) {
+ let style = if region.at("style", default: "italic") == "italic" { config.at("emph-default") } else { region.at("style") }
+ updated.insert("emph", style != "normal")
+ }
+ }
+ }
+ for region in config.at("frame-regions") {
+ let target = _resolve-sequence(alignment, region.at("sequence"))
+ if target == sequence-index {
+ let seq = alignment.at("sequences").at(target)
+ if _selection-columns(seq, region.at("selection"), alignment: alignment).contains(column) {
+ updated.insert("frame", region.at("color"))
+ }
+ }
+ }
+ updated
+}
+
+#let _visible-sequences(alignment, config) = {
+ if config.at("hide-seqs") {
+ return ()
+ }
+ let out = ()
+ for seq in alignment.at("sequences") {
+ let idx = _resolve-sequence(alignment, seq.at("name")) + 1
+ let hidden = config.at("hidden").contains(idx) or config.at("hidden").contains(seq.at("name"))
+ let killed = config.at("killed").contains(idx) or config.at("killed").contains(seq.at("name"))
+ if not hidden and not killed {
+ out.push(seq)
+ }
+ }
+ if config.at("shading").at("mode") == "singleseq" {
+ let ref = config.at("shading").at("reference", default: 1)
+ return (alignment.at("sequences").at(_resolve-sequence(alignment, ref)),)
+ }
+ if config.at("order") == none {
+ return out
+ }
+ let ordered = ()
+ for ref in config.at("order") {
+ let idx = _resolve-sequence(alignment, ref)
+ ordered.push(alignment.at("sequences").at(idx))
+ }
+ ordered
+}
+
+#let _consensus-row(alignment, config, columns, segment) = {
+ let cells = ()
+ for col in segment {
+ let info = _style-for-column(alignment, config, col)
+ let score = info.at("consensus-score")
+ let level = if info.at("consensus") == none {
+ "none"
+ } else if info.at("consensus-forced", default: false) and score < config.at("threshold") {
+ "conserved"
+ } else if score >= config.at("allmatch-threshold") {
+ "allmatch"
+ } else if score >= config.at("threshold") {
+ "conserved"
+ } else {
+ "none"
+ }
+ let char = if level == "none" {
+ config.at("consensus").at("symbols").at("none")
+ } else if level == "allmatch" {
+ let token = config.at("consensus").at("symbols").at("allmatch")
+ if token == "upper" {
+ _upper(info.at("consensus"))
+ } else if token == "lower" {
+ _lower(info.at("consensus"))
+ } else {
+ token
+ }
+ } else if level == "conserved" {
+ let token = config.at("consensus").at("symbols").at("conserved")
+ if token == "upper" {
+ _upper(info.at("consensus"))
+ } else if token == "lower" {
+ _lower(info.at("consensus"))
+ } else {
+ token
+ }
+ } else {
+ config.at("consensus").at("symbols").at("none")
+ }
+ let color-style = config.at("consensus").at("colors").at(level)
+ let bg = if config.at("consensus").at("scale") == none {
+ color-style.at("bg")
+ } else {
+ let scale-score = if config.at("consensus").at("scale") == "T-Coffee" { _column-tcoffee-score(alignment, config, col) } else { score }
+ scale-color(config.at("consensus").at("scale"), scale-score)
+ }
+ cells.push((char: char, fg: color-style.at("fg"), bg: bg, emph: false, frame: none))
+ }
+ (label: config.at("consensus").at("name"), left: "", right: "", cells: cells, row-kind: "consensus")
+}
+
+
+#let _row-for-sequence(alignment, config, sequence, sequence-index, segment) = {
+ let cells = ()
+ let no-shade = _matches-sequence(alignment, config.at("no-shade"), sequence-index)
+ for col in segment {
+ let column-info = _style-for-column(alignment, config, col)
+ let style-info = column-info.at("styles").at(sequence-index)
+ if no-shade {
+ let raw-char = sequence.at("aligned").slice(col, col + 1)
+ if raw-char == "." or raw-char == "-" {
+ style-info = (char: config.at("gaps").at("char"), fg: config.at("gaps").at("fg"), bg: config.at("gaps").at("bg"), emph: false, rule: config.at("gaps").at("char") == "rule")
+ } else {
+ style-info = (char: if raw-char == "*" { config.at("stop-char") } else { raw-char }, fg: "Black", bg: "White", emph: false)
+ }
+ }
+ let cell = (char: style-info.at("char"), fg: style-info.at("fg"), bg: style-info.at("bg"), emph: style-info.at("emph", default: false), frame: none, rule: style-info.at("rule", default: false))
+ cell = _apply-cell-styles(alignment, config, sequence, sequence-index, col, column-info, style-info, cell)
+ cells.push(_apply-regions(alignment, config, sequence-index, col, cell))
+ }
+ let hide-number = _matches-sequence(alignment, config.at("hidden-numbers"), sequence-index)
+ let left-num = if config.at("numbering").at("show") and config.at("numbering").at("left") {
+ let pos = none
+ for col in segment {
+ if sequence.at("positions").at(col) != none {
+ pos = sequence.at("positions").at(col)
+ break
+ }
+ }
+ if pos == none or hide-number { "" } else { str(pos) }
+ } else { "" }
+ let right-num = if config.at("numbering").at("show") and config.at("numbering").at("right") {
+ let pos = none
+ let pos-col = none
+ for col in segment.rev() {
+ if sequence.at("positions").at(col) != none {
+ pos = sequence.at("positions").at(col)
+ pos-col = col
+ break
+ }
+ }
+ if pos != none and sequence.at("length", default: none) != none {
+ let last-col = none
+ for (idx, mapped) in sequence.at("positions").enumerate() {
+ if mapped != none {
+ last-col = idx
+ }
+ }
+ if pos-col == last-col {
+ pos = sequence.at("length")
+ }
+ }
+ if pos == none or hide-number { "" } else { str(pos) }
+ } else { "" }
+ let label = if _matches-sequence(alignment, config.at("hidden-names"), sequence-index) { "" } else { _sequence-label(alignment, config, sequence, sequence-index) }
+ (
+ label: label,
+ label-color: _sequence-color(config, sequence, sequence-index, "sequence-name-colors", config.at("names-color")),
+ number-color: _sequence-color(config, sequence, sequence-index, "sequence-number-colors", config.at("numbering-color")),
+ left: left-num,
+ right: right-num,
+ cells: cells,
+ row-kind: "sequence",
+ )
+}
+
+#let _separation-row(config, segment) = (
+ label: "",
+ left: "",
+ right: "",
+ cells: _array-fill(segment.len(), _empty-cell()),
+ row-kind: "separation",
+ height: config.at("separation-space"),
+)
+
+#let _row-label-target(row) = {
+ let kind = row.at("row-kind")
+ if kind == "feature-text" {
+ "feature-text-labels"
+ } else if kind == "feature" {
+ "feature-style-labels"
+ } else if kind == "ruler" {
+ "ruler-label"
+ } else {
+ "names"
+ }
+}
+
+#let _row-cell-target(row) = {
+ let kind = row.at("row-kind")
+ if kind == "feature-text" {
+ "features"
+ } else if kind == "feature" {
+ "featurestyles"
+ } else if kind == "ruler" {
+ "ruler"
+ } else {
+ "residues"
+ }
+}
+
+#let _name-width(alignment, config) = {
+ if not config.at("names").at("show") {
+ return 0pt
+ }
+ let widths = (0pt,)
+ for seq in alignment.at("sequences") {
+ let idx = _resolve-sequence(alignment, seq.at("name"))
+ widths.push(measure(text(.._text-params(config, "names"))[#_text-string(config, "names", _sequence-label(alignment, config, seq, idx))]).width)
+ }
+ if config.at("consensus").at("show") and config.at("consensus").at("name") != "" {
+ widths.push(measure(text(.._text-params(config, "names"))[#_text-string(config, "names", config.at("consensus").at("name"))]).width)
+ }
+ if config.at("sequence-logo").at("show") {
+ widths.push(measure(text(.._text-params(config, "names"))[#_text-string(config, "names", config.at("sequence-logo").at("name"))]).width)
+ }
+ if config.at("subfamily-logo").at("show") {
+ widths.push(measure(text(.._text-params(config, "names"))[#_text-string(config, "names", config.at("subfamily-logo").at("name"))]).width)
+ widths.push(measure(text(.._text-params(config, "names"))[#_text-string(config, "names", config.at("subfamily-logo").at("negative-name"))]).width)
+ }
+ for label in config.at("feature-style-names").values() {
+ widths.push(measure(text(.._text-params(config, "feature-style-labels"))[#_text-string(config, "feature-style-labels", label)]).width)
+ }
+ for label in config.at("feature-text-names").values() {
+ widths.push(measure(text(.._text-params(config, "feature-text-labels"))[#_text-string(config, "feature-text-labels", label)]).width)
+ }
+ for label in config.at("ruler-names").values() {
+ widths.push(measure(text(.._text-params(config, "ruler-label"))[#_text-string(config, "ruler-label", label)]).width)
+ }
+ calc.max(..widths) + 6pt
+}
+
+#let _render-table(rows, config, name-width, num-width, cell-width) = {
+ let fills = ()
+ let strokes = ()
+ let items = ()
+ let columns = ()
+ if config.at("names").at("show") and config.at("names").at("position") == "left" {
+ columns.push(name-width)
+ }
+ if config.at("numbering").at("show") and config.at("numbering").at("left") {
+ columns.push(num-width)
+ }
+ let residue-cols = rows.first().at("cells").len()
+ for _ in range(0, residue-cols) {
+ columns.push(cell-width)
+ }
+ if config.at("numbering").at("show") and config.at("numbering").at("right") {
+ columns.push(num-width)
+ }
+ if config.at("names").at("show") and config.at("names").at("position") == "right" {
+ columns.push(name-width)
+ }
+ for row in rows {
+ let row-fills = ()
+ let row-strokes = ()
+ let label-color = row.at("label-color", default: "Black")
+ let number-color = row.at("number-color", default: config.at("numbering-color"))
+ let label-target = _row-label-target(row)
+ let cell-target = _row-cell-target(row)
+ let hide-sequence-chars = config.at("hide-residues") and row.at("row-kind") == "sequence"
+ if config.at("names").at("show") and config.at("names").at("position") == "left" {
+ row-fills.push(none)
+ row-strokes.push(none)
+ items.push(align(if config.at("align-right-labels") { right } else { left }, text(.._text-params(config, label-target, fill: resolve-color(label-color)))[#_text-string(config, label-target, row.at("label"))]))
+ }
+ if config.at("numbering").at("show") and config.at("numbering").at("left") {
+ row-fills.push(none)
+ row-strokes.push(none)
+ items.push(align(right, text(.._text-params(config, "numbering", fill: resolve-color(number-color)))[#_text-string(config, "numbering", row.at("left"))]))
+ }
+ for cell in row.at("cells") {
+ row-fills.push(if cell.at("bg") == none { none } else { resolve-color(cell.at("bg")) })
+ row-strokes.push(if cell.at("frame") == none { none } else { (paint: resolve-color(cell.at("frame")), thickness: cell.at("frame-thickness", default: 0.5pt)) })
+ if cell.at("rule", default: false) {
+ items.push(box(width: 100%, height: config.at("font-size"), inset: 0pt)[
+ #align(horizon, rect(width: 100%, height: config.at("gaps").at("rule-thickness"), fill: resolve-color(cell.at("fg")), stroke: none))
+ ])
+ } else if row.at("row-kind") == "separation" {
+ items.push(box(width: 100%, height: row.at("height"), inset: 0pt)[])
+ } else if cell.keys().contains("rotated") {
+ items.push(box(width: 100%, height: config.at("font-size") * 3, inset: 0pt)[
+ #align(center + horizon, rotate(-90deg, reflow: false)[
+ #text(.._text-params(config, cell-target, fill: resolve-color(cell.at("fg"))))[#_text-string(config, cell-target, cell.at("rotated"))]
+ ])
+ ])
+ } else if cell.keys().contains("ruler-label") {
+ items.push(box(width: 100%, inset: 0pt)[
+ #align(center + horizon)[
+ #text(.._text-params(config, cell-target, fill: resolve-color(cell.at("fg"))))[#_text-string(config, cell-target, cell.at("ruler-label"))]
+ ]
+ ])
+ } else {
+ items.push(text(.._text-params(config, cell-target, fill: resolve-color(cell.at("fg")), style: if cell.at("emph") { "italic" } else { auto }, size: if cell.keys().contains("size") { _font-size(config, cell.at("size")) } else { auto }))[#{
+ if hide-sequence-chars or (cell.at("char") == "." and not config.at("show-leading-gaps")) { "" } else { _text-string(config, cell-target, cell.at("char")) }
+ }])
+ }
+ }
+ if config.at("numbering").at("show") and config.at("numbering").at("right") {
+ row-fills.push(none)
+ row-strokes.push(none)
+ items.push(align(left, text(.._text-params(config, "numbering", fill: resolve-color(number-color)))[#_text-string(config, "numbering", row.at("right"))]))
+ }
+ if config.at("names").at("show") and config.at("names").at("position") == "right" {
+ row-fills.push(none)
+ row-strokes.push(none)
+ items.push(align(if config.at("align-right-labels") { left } else { right }, text(.._text-params(config, label-target, fill: resolve-color(label-color)))[#_text-string(config, label-target, row.at("label"))]))
+ }
+ fills.push(row-fills)
+ strokes.push(row-strokes)
+ }
+ let table-width = 0pt
+ for column in columns {
+ table-width += column
+ }
+ box(width: table-width, inset: 0pt)[
+ #table(columns: columns, inset: (x: 2pt, y: 1pt), stroke: (x, y) => strokes.at(y).at(x), fill: (x, y) => fills.at(y).at(x), align: center, column-gutter: 0pt, row-gutter: config.at("line-gap"), ..items)
+ ]
+}
+
+#let _append-feature-blocks(rendered, alignment, config, segment, slots, name-width, num-width, cell-width) = {
+ for slot in slots {
+ let rows = _feature-rows(alignment, config, segment, slot)
+ if rows.len() > 0 {
+ rendered.push(_render-table(rows, config, name-width, num-width, cell-width))
+ let spacing = config.at("feature-slot-spacing").at(slot)
+ if spacing != 0pt {
+ rendered.push(v(spacing, weak: false))
+ }
+ }
+ }
+}
+
+#let _alignment-edge(config) = {
+ let value = config.at("alignment")
+ if value == "left" {
+ left
+ } else if value == "right" {
+ right
+ } else {
+ center
+ }
+}
+
+#let _line-count-is-auto(value) = value == auto or str(value) == "auto"
+
+#let _label-number-width(config, name-width, num-width) = {
+ let fixed = 0pt
+ if config.at("names").at("show") {
+ fixed += name-width
+ }
+ if config.at("numbering").at("show") and config.at("numbering").at("left") {
+ fixed += num-width
+ }
+ if config.at("numbering").at("show") and config.at("numbering").at("right") {
+ fixed += num-width
+ }
+ fixed
+}
+
+#let _resolved-residues-per-line(config, display-columns, available-width, cell-width, name-width, num-width) = {
+ let requested = config.at("residues-per-line")
+ if not _line-count-is-auto(requested) {
+ return calc.max(1, int(requested))
+ }
+ let limits = config.at("auto-layout")
+ let lower = calc.max(1, int(limits.at("min", default: 1)))
+ let upper = limits.at("max", default: none)
+ let clamp = value => {
+ let bounded = calc.max(lower, value)
+ if upper == none {
+ bounded
+ } else {
+ calc.min(int(upper), bounded)
+ }
+ }
+ if available-width == none or available-width == auto {
+ return clamp(calc.max(1, calc.min(60, display-columns.len())))
+ }
+ if available-width > 10000pt {
+ return clamp(calc.max(1, calc.min(60, display-columns.len())))
+ }
+ let fixed = _label-number-width(config, name-width, num-width)
+ let usable = calc.max(cell-width, available-width - fixed - 6pt)
+ clamp(calc.max(1, calc.min(display-columns.len(), int(calc.floor(usable / cell-width)))))
+}
+
+#let _resolved-blocks-per-page(alignment, config, available-height) = {
+ let page = config.at("auto-page")
+ if not page.at("enabled") {
+ return none
+ }
+ let requested = page.at("blocks")
+ if requested != auto and requested != none {
+ return calc.max(1, int(requested))
+ }
+ if requested == none or available-height == none or available-height == auto or available-height > 10000pt {
+ return none
+ }
+ let sequence-rows = _visible-sequences(alignment, config).len()
+ let consensus-rows = if config.at("consensus").at("show") { 1 } else { 0 }
+ let ruler-rows = if config.at("ruler").at("show") { 2 } else { 0 }
+ let logo-rows = (if config.at("sequence-logo").at("show") { 3 } else { 0 }) + (if config.at("subfamily-logo").at("show") and config.at("subfamily").len() > 0 { 3 } else { 0 })
+ let feature-count = config.at("features").len() + config.at("structure-data").len()
+ let feature-rows = calc.min(6, feature-count)
+ let rows = calc.max(1, sequence-rows + consensus-rows + ruler-rows + logo-rows + feature-rows)
+ let row-height = config.at("font-size") + config.at("line-gap") + 2pt
+ let estimated = rows * row-height + config.at("block-gap")
+ calc.max(1, int(calc.floor(available-height / estimated)))
+}
+
+#let _legend-style-label(kind) = {
+ if kind == "allmatch" {
+ "all match"
+ } else if kind == "conserved" {
+ "conserved"
+ } else if kind == "similar" {
+ "similar"
+ } else if kind == "gap" {
+ "gap"
+ } else if kind == "tcoffee" {
+ "T-Coffee"
+ } else {
+ kind
+ }
+}
+
+#let _legend-key-part(value) = {
+ if value == none {
+ "none"
+ } else if type(value) == color {
+ "color"
+ } else {
+ str(value)
+ }
+}
+
+#let _legend-items(alignment, config, columns) = {
+ if not config.at("legend").at("show") or config.at("shading").at("mode") == "functional" {
+ return ()
+ }
+ let items = (:)
+ let wanted = ("similar", "conserved", "allmatch", "gap", "tcoffee")
+ for col in columns {
+ let info = _style-for-column(alignment, config, col)
+ for style in info.at("styles") {
+ let kind = style.at("kind", default: "nomatch")
+ if wanted.contains(kind) {
+ let key = kind + ":" + _legend-key-part(style.at("fg", default: none)) + ":" + _legend-key-part(style.at("bg", default: none))
+ if not items.keys().contains(key) {
+ items.insert(key, (
+ label: _legend-style-label(kind),
+ fg: style.at("fg", default: "Black"),
+ bg: style.at("bg", default: "White"),
+ ))
+ }
+ }
+ }
+ }
+ if config.at("cell-styles").len() > 0 and not items.keys().contains("cell-style") {
+ items.insert("cell-style", (label: "cell-style", fg: "Black", bg: "Gray10"))
+ }
+ items.values()
+}
+
+#let _render-block-stack(config, edge, rendered) = {
+ let aligned = rendered.map(item => align(edge)[#item])
+ if config.at("fixed-block-space") {
+ return stack(spacing: config.at("block-gap"), ..aligned)
+ }
+ let body = []
+ for (idx, item) in aligned.enumerate() {
+ if idx > 0 and config.at("block-gap") != 0pt {
+ body += v(config.at("block-gap"), weak: true)
+ }
+ body += item
+ }
+ body
+}
+
+#let render-alignment(source, format: auto, commands: (), font: none, font-size: none, residues-per-line: none) = {
+ context {
+ let config = _default-config()
+ if font != none {
+ config.insert("font", font)
+ }
+ if font-size != none {
+ config.insert("font-size", font-size)
+ }
+ if residues-per-line != none {
+ config.insert("residues-per-line", residues-per-line)
+ }
+ for command in commands {
+ config = _apply-command(config, command)
+ }
+ let alignment = read-alignment(source, format: format)
+ if config.at("seq-type") != none {
+ alignment.insert("seq-type", config.at("seq-type"))
+ }
+ let _ = _validate-config-sequence-refs(alignment, config)
+ _apply-single-sequence-options(alignment, config)
+ _apply-numbering-overrides(alignment, config)
+ let render = (available-width, available-height) => {
+ let sequences = _visible-sequences(alignment, config)
+ let display-columns = _display-columns(alignment, config)
+ let cell-width = (measure(text(.._text-params(config, "residues"))[M]).width + 1.5pt) * config.at("char-stretch")
+ let name-width = _name-width(alignment, config)
+ let num-sample = "9" * config.at("numbering-width-digits")
+ let num-width = measure(text(.._text-params(config, "numbering"))[#num-sample]).width + 4pt
+ let blocks = ()
+ let step = _resolved-residues-per-line(config, display-columns, available-width, cell-width, name-width, num-width)
+ let start = 0
+ while start < display-columns.len() {
+ let stop = calc.min(start + step, display-columns.len())
+ blocks.push(display-columns.slice(start, stop))
+ start = stop
+ }
+ let rendered = ()
+ if config.at("captions").at("top") != none {
+ rendered.push(text(.._text-params(config, "legend"))[#config.at("captions").at("top")])
+ }
+ let legend = _legend-block(config, items: _legend-items(alignment, config, display-columns))
+ if legend != none {
+ rendered.push(legend)
+ }
+ let blocks-per-page = _resolved-blocks-per-page(alignment, config, available-height)
+ for (segment-index, segment) in blocks.enumerate() {
+ _append-feature-blocks(rendered, alignment, config, segment, _top-feature-slots, name-width, num-width, cell-width)
+ let rows = ()
+ if config.at("ruler").at("show") and config.at("ruler").at("position") == "top" {
+ for row in _ruler-rows(alignment, config, segment, "top") {
+ rows.push(row)
+ }
+ if config.at("ruler-spacing").at("top") != 0pt {
+ rendered.push(_render-table(rows, config, name-width, num-width, cell-width))
+ rendered.push(v(config.at("ruler-spacing").at("top"), weak: false))
+ rows = ()
+ }
+ }
+ if config.at("sequence-logo").at("show") and config.at("sequence-logo").at("position") == "top" {
+ rendered.push(_logo-block(alignment, config, segment, name-width, num-width, cell-width))
+ }
+ if config.at("subfamily-logo").at("show") and config.at("subfamily-logo").at("position") == "top" and config.at("subfamily").len() > 0 {
+ rendered.push(_logo-block(alignment, config, segment, name-width, num-width, cell-width, subfamily: true))
+ }
+ if config.at("consensus").at("show") and config.at("consensus").at("position") == "top" {
+ rows.push(_consensus-row(alignment, config, display-columns, segment))
+ }
+ for seq in sequences {
+ let seq-index = _resolve-sequence(alignment, seq.at("name"))
+ rows.push(_row-for-sequence(alignment, config, seq, seq-index, segment))
+ if _matches-sequence(alignment, config.at("separation-lines"), seq-index) {
+ rows.push(_separation-row(config, segment))
+ }
+ }
+ if config.at("consensus").at("show") and config.at("consensus").at("position") == "bottom" {
+ rows.push(_consensus-row(alignment, config, display-columns, segment))
+ }
+ if config.at("sequence-logo").at("show") and config.at("sequence-logo").at("position") == "bottom" {
+ rows.push((label: "", left: "", right: "", cells: _array-fill(segment.len(), _empty-cell()), row-kind: "spacer"))
+ rendered.push(_render-table(rows, config, name-width, num-width, cell-width))
+ rendered.push(_logo-block(alignment, config, segment, name-width, num-width, cell-width))
+ rows = ()
+ }
+ if config.at("subfamily-logo").at("show") and config.at("subfamily-logo").at("position") == "bottom" and config.at("subfamily").len() > 0 {
+ if rows.len() > 0 {
+ rendered.push(_render-table(rows, config, name-width, num-width, cell-width))
+ rows = ()
+ }
+ rendered.push(_logo-block(alignment, config, segment, name-width, num-width, cell-width, subfamily: true))
+ }
+ if config.at("ruler").at("show") and config.at("ruler").at("position") == "bottom" {
+ if rows.len() > 0 and config.at("ruler-spacing").at("bottom") != 0pt {
+ rendered.push(_render-table(rows, config, name-width, num-width, cell-width))
+ rendered.push(v(config.at("ruler-spacing").at("bottom"), weak: false))
+ rows = ()
+ }
+ for row in _ruler-rows(alignment, config, segment, "bottom") {
+ rows.push(row)
+ }
+ }
+ if rows.len() > 0 {
+ rendered.push(_render-table(rows, config, name-width, num-width, cell-width))
+ }
+ _append-feature-blocks(rendered, alignment, config, segment, _bottom-feature-slots, name-width, num-width, cell-width)
+ if blocks-per-page != none and segment-index + 1 < blocks.len() and calc.rem(segment-index + 1, blocks-per-page) == 0 {
+ rendered.push(pagebreak(weak: true))
+ if legend != none and config.at("auto-page").at("repeat-legend") {
+ rendered.push(legend)
+ }
+ }
+ }
+ if config.at("captions").at("bottom") != none {
+ rendered.push(text(.._text-params(config, "legend"))[#config.at("captions").at("bottom")])
+ }
+ let edge = _alignment-edge(config)
+ _render-block-stack(config, edge, rendered)
+ }
+ if _line-count-is-auto(config.at("residues-per-line")) or config.at("auto-page").at("blocks") == auto {
+ layout(size => render(size.width, size.height))
+ } else {
+ render(none, none)
+ }
+ }
+}
diff --git a/packages/preview/typshade/0.1.3/internal/render/features.typ b/packages/preview/typshade/0.1.3/internal/render/features.typ
new file mode 100644
index 0000000000..9c329c9b5e
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/render/features.typ
@@ -0,0 +1,388 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "graphs.typ": _graph-rows, _graph-style
+#import "../engine/layout.typ": _empty-cell, _ordinal-label, _selection-columns, _selection-string
+#import "../model/logo.typ": _resolve-sequence
+#import "../model/parser.typ": _array-fill, _chars, _lower, _upper
+
+#let _top-feature-slots = ("ttttop", "tttop", "ttop", "top")
+#let _bottom-feature-slots = ("bottom", "bbottom", "bbbottom", "bbbbottom")
+
+#let _array-position(items, value) = {
+ for (idx, item) in items.enumerate() {
+ if item == value {
+ return idx
+ }
+ }
+ none
+}
+
+#let _feature-label(config, position, text: false) = {
+ let labels = if text { config.at("feature-text-names") } else { config.at("feature-style-names") }
+ let colors = if text { config.at("feature-text-name-colors") } else { config.at("feature-style-name-colors") }
+ (
+ text: labels.at(position, default: ""),
+ color: colors.at(position, default: colors.at("default")),
+ )
+}
+
+#let _roman(index, upper: false) = {
+ let pairs = ((1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"), (50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i"))
+ let value = index
+ let out = ""
+ for pair in pairs {
+ while value >= pair.at(0) {
+ out += pair.at(1)
+ value -= pair.at(0)
+ }
+ }
+ if upper { _upper(out) } else { out }
+}
+
+#let _structure-template(text, number, letter) = {
+ let lower-letter = _lower(letter)
+ str(text)
+ .replace("\\numcount", str(number))
+ .replace("\\alphacount", lower-letter)
+ .replace("\\Alphacount", letter)
+ .replace("\\romancount", _roman(number))
+ .replace("\\Romancount", _roman(number, upper: true))
+ .replace("$\\alpha$", "α")
+ .replace("$\\beta$", "β")
+ .replace("$\\pi$", "π")
+ .replace("$\\circ$", "○")
+ .replace("$\\uparrow$", "↑")
+ .replace("$\\diamond$", "◇")
+ .replace("$_{10}$", "10")
+ .replace("$", "")
+}
+
+#let _structure-items(alignment, config) = {
+ let items = ()
+ for entry in config.at("structure-data") {
+ let prefix = entry.at("kind")
+ let sequence = entry.at("sequence")
+ let shown = config.at("structure-show").at(prefix, default: ())
+ let data = entry.at("data")
+ if prefix == "HMMTOP" or prefix == "PHDtopo" {
+ for (idx, range) in data.at("internal", default: ()).enumerate() {
+ if shown.contains("internal") {
+ let appearance = config.at("structure-appearance").at(prefix + ":internal")
+ let letter = _ordinal-label(idx + 1)
+ items.push((position: appearance.at("position"), sequence: sequence, selection: _selection-string(range), style: _structure-template(appearance.at("style"), idx + 1, letter), text: _structure-template(appearance.at("text"), idx + 1, letter)))
+ }
+ }
+ for (idx, range) in data.at("external", default: ()).enumerate() {
+ if shown.contains("external") {
+ let appearance = config.at("structure-appearance").at(prefix + ":external")
+ let letter = _ordinal-label(idx + 1)
+ items.push((position: appearance.at("position"), sequence: sequence, selection: _selection-string(range), style: _structure-template(appearance.at("style"), idx + 1, letter), text: _structure-template(appearance.at("text"), idx + 1, letter)))
+ }
+ }
+ for (idx, range) in data.at("spans", default: data.at("tm", default: ())).enumerate() {
+ if shown.contains("TM") {
+ let appearance = config.at("structure-appearance").at(prefix + ":TM")
+ let letter = _ordinal-label(idx + 1)
+ items.push((position: appearance.at("position"), sequence: sequence, selection: _selection-string(range), style: _structure-template(appearance.at("style"), idx + 1, letter), text: _structure-template(appearance.at("text"), idx + 1, letter)))
+ }
+ }
+ } else if prefix == "PHDsec" {
+ for (idx, range) in data.at("alpha", default: ()).enumerate() {
+ if shown.contains("alpha") {
+ let appearance = config.at("structure-appearance").at("PHDsec:alpha")
+ let letter = _ordinal-label(idx + 1)
+ items.push((position: appearance.at("position"), sequence: sequence, selection: _selection-string(range), style: _structure-template(appearance.at("style"), idx + 1, letter), text: _structure-template(appearance.at("text"), idx + 1, letter)))
+ }
+ }
+ for (idx, range) in data.at("beta", default: ()).enumerate() {
+ if shown.contains("beta") {
+ let appearance = config.at("structure-appearance").at("PHDsec:beta")
+ let letter = _ordinal-label(idx + 1)
+ items.push((position: appearance.at("position"), sequence: sequence, selection: _selection-string(range), style: _structure-template(appearance.at("style"), idx + 1, letter), text: _structure-template(appearance.at("text"), idx + 1, letter)))
+ }
+ }
+ } else if prefix == "STRIDE" or prefix == "DSSP" {
+ for key in shown {
+ for (idx, range) in data.at(key, default: ()).enumerate() {
+ let appearance = config.at("structure-appearance").at(prefix + ":" + key)
+ let letter = _ordinal-label(idx + 1)
+ items.push((position: appearance.at("position"), sequence: sequence, selection: _selection-string(range), style: _structure-template(appearance.at("style"), idx + 1, letter), text: _structure-template(appearance.at("text"), idx + 1, letter)))
+ }
+ }
+ }
+ }
+ items
+}
+
+#let _base-matches(pattern, base) = {
+ let map = (
+ A: "A", C: "C", G: "G", T: "TU", U: "TU",
+ R: "AG", Y: "CTU", K: "GTU", M: "AC", S: "CG", W: "ATU",
+ B: "CGTU", D: "AGTU", H: "ACTU", V: "ACG", N: "ACGTU",
+ )
+ map.at(_upper(pattern), default: _upper(pattern)).contains(_upper(base))
+}
+
+#let _codon-matches(pattern, codon) = {
+ if pattern.len() != 3 or codon.len() != 3 {
+ return false
+ }
+ for idx in range(0, 3) {
+ if not _base-matches(pattern.slice(idx, idx + 1), codon.slice(idx, idx + 1)) {
+ return false
+ }
+ }
+ true
+}
+
+#let _translate-codon(config, codon) = {
+ let cleaned = _upper(str(codon)).replace("U", "T")
+ for residue in config.at("genetic-code").keys() {
+ if _codon-matches(config.at("genetic-code").at(residue).replace("U", "T"), cleaned) {
+ return residue
+ }
+ }
+ "X"
+}
+
+#let _complement-base(base) = {
+ let pairs = (A: "T", T: "A", U: "A", G: "C", C: "G", R: "Y", Y: "R", K: "M", M: "K", S: "S", W: "W", B: "V", V: "B", D: "H", H: "D", N: "N")
+ pairs.at(_upper(base), default: _upper(base))
+}
+
+#let _style-colors(style, default-fg: "Black", default-bg: none) = {
+ let hit = str(style).matches(regex("\\[([^\\]]+)\\]"))
+ if hit.len() == 0 {
+ return (fg: default-fg, bg: default-bg)
+ }
+ let parts = hit.first().captures.at(0).split(",").map(part => part.trim())
+ (fg: if parts.len() > 0 and parts.first() != "" { parts.first() } else { default-fg }, bg: if parts.len() > 1 and parts.at(1) != "" { parts.at(1) } else { default-bg })
+}
+
+#let _backtranslation-label(config, triplet, index) = {
+ let style = config.at("backtranslation").at("label-style")
+ if style == "vertical" {
+ triplet.slice(0, 1) + "\n" + triplet.slice(1, 2) + "\n" + triplet.slice(2, 3)
+ } else if style == "oblique" {
+ triplet.slice(0, 1) + "/" + triplet.slice(1, 2) + "/" + triplet.slice(2, 3)
+ } else if style == "zigzag" {
+ if calc.rem(index, 2) == 0 { triplet } else { triplet.slice(2, 3) + triplet.slice(1, 2) + triplet.slice(0, 1) }
+ } else {
+ triplet
+ }
+}
+
+#let _translation-cells(alignment, config, sequence, style, segment, selected) = {
+ let cells = _array-fill(segment.len(), _empty-cell())
+ let colors = _style-colors(style)
+ if alignment.at("seq-type") == "P" {
+ for (idx, col) in segment.enumerate() {
+ if selected.contains(col) {
+ let residue = _upper(sequence.at("aligned").slice(col, col + 1))
+ if residue != "." and residue != "-" {
+ cells.at(idx).insert("char", _backtranslation-label(config, config.at("genetic-code").at(residue, default: "NNN"), idx))
+ cells.at(idx).insert("fg", colors.at("fg"))
+ cells.at(idx).insert("bg", colors.at("bg"))
+ cells.at(idx).insert("size", config.at("backtranslation").at("label-size"))
+ }
+ }
+ }
+ return cells
+ }
+ let codon = ""
+ let codon-cols = ()
+ for col in selected {
+ let residue = sequence.at("aligned").slice(col, col + 1)
+ if residue == "." or residue == "-" {
+ continue
+ }
+ codon += residue
+ codon-cols.push(col)
+ if codon.len() == 3 {
+ let center-col = codon-cols.at(1)
+ let target = _array-position(segment, center-col)
+ if target != none {
+ cells.at(target).insert("char", _translate-codon(config, codon))
+ cells.at(target).insert("fg", colors.at("fg"))
+ cells.at(target).insert("bg", colors.at("bg"))
+ cells.at(target).insert("size", config.at("backtranslation").at("text-size"))
+ }
+ codon = ""
+ codon-cols = ()
+ }
+ }
+ cells
+}
+
+#let _complement-cells(config, sequence, style, segment, selected) = {
+ let cells = _array-fill(segment.len(), _empty-cell())
+ let colors = _style-colors(style)
+ let lower = str(style).contains("[lower]")
+ for (idx, col) in segment.enumerate() {
+ if selected.contains(col) {
+ let residue = sequence.at("aligned").slice(col, col + 1)
+ if residue != "." and residue != "-" {
+ let base = _complement-base(residue)
+ cells.at(idx).insert("char", if lower { _lower(base) } else { base })
+ cells.at(idx).insert("fg", colors.at("fg"))
+ cells.at(idx).insert("bg", colors.at("bg"))
+ }
+ }
+ }
+ cells
+}
+
+#let _feature-cell(config, style, col, selected) = {
+ if not selected.contains(col) {
+ return _empty-cell()
+ }
+ if style == "" {
+ return (char: "•", fg: "Black", bg: "LightGray", emph: false, frame: none)
+ }
+ if style.starts-with("box") {
+ let fill = "LightGray"
+ let hit = style.matches(regex("^box\\[([^\\]]+)\\]"))
+ if hit.len() > 0 {
+ fill = hit.first().captures.at(0)
+ }
+ return (char: "", fg: "Black", bg: fill, emph: false, frame: "Black", frame-thickness: config.at("feature-rule"))
+ }
+ if style == "helix" {
+ return (char: "≋", fg: "Black", bg: none, emph: false, frame: none)
+ }
+ if style.starts-with("fill:") {
+ let symbol = style.split(":").last()
+ return (char: if symbol.len() > 0 { symbol.slice(0, 1) } else { "•" }, fg: "Black", bg: none, emph: false, frame: none)
+ }
+ if style.starts-with("brace") {
+ return (char: "⌒", fg: "Black", bg: none, emph: false, frame: none)
+ }
+ if style.contains("=") {
+ return (char: "━", fg: "Black", bg: none, emph: false, frame: none)
+ }
+ if style.contains("-") or style.contains(">") or style.contains("<") or style.contains(",") {
+ return (char: "─", fg: "Black", bg: none, emph: false, frame: none)
+ }
+ (char: style.slice(0, 1), fg: "Black", bg: "LightGray", emph: false, frame: none)
+}
+
+#let _ruler-rows(alignment, config, segment, position) = {
+ let seq = alignment.at("sequences").at(_resolve-sequence(alignment, config.at("ruler").at("sequence")))
+ let steps = config.at("ruler").at("steps")
+ let ruler-color = config.at("ruler-colors").at(position, default: config.at("ruler").at("color"))
+ let ruler-name = config.at("ruler-names").at(position, default: "")
+ let ruler-name-color = config.at("ruler-name-colors").at(position, default: ruler-color)
+ let alt-labels = config.at("ruler-labels").at(position, default: (:))
+ let rotated = config.at("ruler-rotation").at(position, default: false)
+ let ticks = ()
+ let labels = _array-fill(segment.len(), _empty-cell())
+ for col in segment {
+ let pos = seq.at("positions").at(col)
+ if pos != none and calc.rem(pos, steps) == 0 {
+ ticks.push((char: "|", fg: ruler-color, bg: none, emph: false, frame: none))
+ } else {
+ ticks.push(_empty-cell())
+ }
+ }
+ for (idx, col) in segment.enumerate() {
+ let pos = seq.at("positions").at(col)
+ if pos != none and calc.rem(pos, steps) == 0 {
+ let override = alt-labels.at(str(pos), default: none)
+ let text = if override == none { str(pos) } else { override.at("text") }
+ let color = if override == none or override.at("color") == none { ruler-color } else { override.at("color") }
+ if rotated {
+ labels.at(idx).insert("char", "")
+ labels.at(idx).insert("rotated", text)
+ labels.at(idx).insert("fg", color)
+ } else {
+ labels.at(idx).insert("char", "")
+ labels.at(idx).insert("ruler-label", text)
+ labels.at(idx).insert("fg", color)
+ }
+ }
+ }
+ let label-row = (label: ruler-name, label-color: ruler-name-color, left: "", right: "", cells: labels, row-kind: "ruler")
+ let tick-row = (label: "", left: "", right: "", cells: ticks, row-kind: "ruler")
+ if position == "bottom" {
+ (tick-row, label-row)
+ } else {
+ (label-row, tick-row)
+ }
+}
+
+#let _feature-rows(alignment, config, segment, position) = {
+ let rows = ()
+ let entries = ()
+ for item in config.at("features") {
+ entries.push(item)
+ }
+ for item in _structure-items(alignment, config) {
+ entries.push(item)
+ }
+ for item in entries {
+ if item.at("position") != position {
+ continue
+ }
+ let seq = alignment.at("sequences").at(_resolve-sequence(alignment, item.at("sequence")))
+ let selected = _selection-columns(seq, item.at("selection"), alignment: alignment)
+ let graph = _graph-style(item.at("style"))
+ let cells = if graph != none {
+ none
+ } else if str(item.at("style")).starts-with("translate") {
+ _translation-cells(alignment, config, seq, item.at("style"), segment, selected)
+ } else if str(item.at("style")).starts-with("complement") {
+ _complement-cells(config, seq, item.at("style"), segment, selected)
+ } else {
+ let out = ()
+ for col in segment {
+ out.push(_feature-cell(config, item.at("style"), col, selected))
+ }
+ out
+ }
+ if graph != none {
+ let style-label = _feature-label(config, position)
+ for (row-index, graph-row) in _graph-rows(alignment, config, seq, selected, segment, item.at("style"), position: position).enumerate() {
+ rows.push((
+ label: if row-index == 0 { style-label.at("text") } else { "" },
+ label-color: style-label.at("color"),
+ left: "",
+ right: "",
+ cells: graph-row,
+ row-kind: "feature",
+ ))
+ }
+ } else {
+ let style-label = _feature-label(config, position)
+ rows.push((label: style-label.at("text"), label-color: style-label.at("color"), left: "", right: "", cells: cells, row-kind: "feature"))
+ }
+ let style-text = if type(item.at("style")) == str { item.at("style") } else { "" }
+ let inline-text = if style-text.starts-with("box") and style-text.contains(":") {
+ style-text.split(":").last()
+ } else {
+ ""
+ }
+ let label = if item.at("text") != "" { item.at("text") } else { inline-text }
+ if label != "" {
+ let text-label = _feature-label(config, position, text: true)
+ let label-cells = _array-fill(segment.len(), _empty-cell())
+ let in-segment = ()
+ for (idx, col) in segment.enumerate() {
+ if selected.contains(col) {
+ in-segment.push(idx)
+ }
+ }
+ if in-segment.len() > 0 {
+ let start = calc.max(0, calc.min(in-segment.first(), in-segment.last()) - 1)
+ for (offset, ch) in _chars(label).enumerate() {
+ let target = start + offset
+ if target < label-cells.len() {
+ label-cells.at(target).insert("char", ch)
+ }
+ }
+ }
+ rows.push((label: text-label.at("text"), label-color: text-label.at("color"), left: "", right: "", cells: label-cells, row-kind: "feature-text"))
+ }
+ }
+ rows
+}
diff --git a/packages/preview/typshade/0.1.3/internal/render/graphs.typ b/packages/preview/typshade/0.1.3/internal/render/graphs.typ
new file mode 100644
index 0000000000..cdd9388e31
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/render/graphs.typ
@@ -0,0 +1,626 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../engine/layout.typ": _empty-cell
+#import "../model/palette.typ": resolve-color, scale-color
+#import "../model/parser.typ": _source-is-path, _source-text, _split-lines, _upper
+
+#let _hydropathy-scale = (
+ A: 1.8, C: 2.5, D: -3.5, E: -3.5, F: 2.8, G: -0.4, H: -3.2, I: 4.5,
+ K: -3.9, L: 3.8, M: 1.9, N: -3.5, P: -1.6, Q: -3.5, R: -4.5, S: -0.8,
+ T: -0.7, V: 4.2, W: -0.9, Y: -1.3,
+)
+
+#let _molweight-scale = (
+ A: 89.1, C: 121.2, D: 133.1, E: 147.1, F: 165.2, G: 75.1, H: 155.2, I: 131.2,
+ K: 146.2, L: 131.2, M: 149.2, N: 132.1, P: 115.1, Q: 146.1, R: 174.2, S: 105.1,
+ T: 119.1, V: 117.1, W: 204.2, Y: 181.2,
+)
+
+#let _charge-scale = (
+ D: -1.0, E: -1.0, K: 1.0, R: 1.0, H: 0.5,
+)
+
+#let _graph-scales = ("BlackWhite", "WhiteBlack", "BlueRed", "RedBlue", "GreenRed", "RedGreen", "ColdHot", "HotCold", "TCoffee")
+#let _builtin-graph-metrics = ("hydrophobicity", "molweight", "charge", "conservation", "entropy", "gap-fraction", "gaps", "coverage", "identity", "identity-to-reference")
+
+#let _array-position(items, value) = {
+ for (idx, item) in items.enumerate() {
+ if item == value {
+ return idx
+ }
+ }
+ none
+}
+
+#let _graph-is-stacked(parsed) = parsed.at("kind") == "stackedbars" or parsed.at("kind") == "frustratometer"
+
+#let _parse-number(value) = {
+ let text = str(value).trim()
+ if text == "" or text == "NaN" or text == "nan" {
+ none
+ } else {
+ float(text)
+ }
+}
+
+#let _graph-style(style) = {
+ if type(style) == dictionary {
+ if not style.keys().contains("kind") or not style.keys().contains("metric") {
+ return none
+ }
+ return (
+ kind: style.at("kind"),
+ metric: style.at("metric"),
+ min: style.at("min", default: none),
+ max: style.at("max", default: none),
+ options: style.at("options", default: ()),
+ )
+ }
+ let text = str(style).trim()
+ let hit = text.matches(regex("^(bar|color|stackedbars|frustratometer)(?:\\[([^\\]]*)\\])?:(.+?)(?:\\[([^\\]]*)\\])?$"))
+ if hit.len() == 0 {
+ return none
+ }
+ let captures = hit.first().captures
+ let kind = captures.at(0)
+ let range-text = captures.at(1, default: "")
+ let metric = captures.at(2).trim()
+ let option-text = captures.at(3, default: "")
+ let min = none
+ let max = none
+ if range-text != "" {
+ let parts = range-text.split(",")
+ if parts.len() >= 2 {
+ min = _parse-number(parts.at(0))
+ max = _parse-number(parts.at(1))
+ }
+ }
+ let options = if option-text == "" { () } else { option-text.split(",").map(part => part.trim()) }
+ (kind: kind, metric: metric, min: min, max: max, options: options)
+}
+
+#let _graph-range(metric) = if metric == "hydrophobicity" {
+ (-4.5, 4.5)
+} else if metric == "molweight" {
+ (75.1, 204.2)
+} else if metric == "charge" {
+ (-1.0, 1.0)
+} else {
+ (0.0, 100.0)
+}
+
+#let _graph-range-from-style(parsed, series: none) = {
+ if parsed.at("min") != none and parsed.at("max") != none {
+ return (parsed.at("min"), parsed.at("max"))
+ }
+ if parsed.at("kind") == "frustratometer" {
+ return (0.0, 1.0)
+ }
+ if _builtin-graph-metrics.contains(parsed.at("metric")) {
+ return _graph-range(parsed.at("metric"))
+ }
+ let min = none
+ let max = none
+ for item in series {
+ let values = if type(item) == array or type(item) == content { item } else { (item,) }
+ for value in values {
+ if value == none {
+ continue
+ }
+ if min == none or value < min {
+ min = value
+ }
+ if max == none or value > max {
+ max = value
+ }
+ }
+ }
+ if min == none or max == none {
+ (0.0, 1.0)
+ } else if min == max {
+ (min, max + 1.0)
+ } else {
+ (min, max)
+ }
+}
+
+#let _graph-column-counts(alignment, col) = {
+ let counts = (:)
+ let total = 0
+ let gaps = 0
+ for seq in alignment.at("sequences") {
+ let residue = _upper(seq.at("aligned").slice(col, col + 1))
+ if residue == "." or residue == "-" or residue == "" {
+ gaps += 1
+ continue
+ }
+ counts.insert(residue, counts.at(residue, default: 0) + 1)
+ total += 1
+ }
+ (counts: counts, total: total, gaps: gaps)
+}
+
+#let _graph-conservation(alignment, col) = {
+ let data = _graph-column-counts(alignment, col)
+ let counts = data.at("counts")
+ let total = data.at("total")
+ if total == 0 {
+ return 0.0
+ }
+ let top = 0
+ for key in counts.keys() {
+ if counts.at(key) > top {
+ top = counts.at(key)
+ }
+ }
+ top / total * 100.0
+}
+
+#let _graph-entropy(alignment, col) = {
+ let data = _graph-column-counts(alignment, col)
+ let counts = data.at("counts")
+ let total = data.at("total")
+ if total == 0 {
+ return 0.0
+ }
+ let entropy = 0.0
+ for key in counts.keys() {
+ let p = counts.at(key) / total
+ if p > 0 {
+ entropy += -p * calc.log(p) / calc.log(2)
+ }
+ }
+ let max-entropy = if alignment.at("seq-type") == "N" { 2.0 } else { calc.log(20.0) / calc.log(2) }
+ calc.max(0.0, calc.min(100.0, entropy / max-entropy * 100.0))
+}
+
+#let _graph-gap-fraction(alignment, col) = _graph-column-counts(alignment, col).at("gaps") / alignment.at("sequences").len() * 100.0
+
+#let _graph-coverage(alignment, col) = _graph-column-counts(alignment, col).at("total") / alignment.at("sequences").len() * 100.0
+
+#let _graph-identity(alignment, sequence, col) = {
+ let residue = _upper(sequence.at("aligned").slice(col, col + 1))
+ if residue == "." or residue == "-" or residue == "" {
+ return none
+ }
+ let total = 0
+ let hits = 0
+ for seq in alignment.at("sequences") {
+ let other = _upper(seq.at("aligned").slice(col, col + 1))
+ if other == "." or other == "-" or other == "" {
+ continue
+ }
+ total += 1
+ if other == residue {
+ hits += 1
+ }
+ }
+ if total == 0 { none } else { hits / total * 100.0 }
+}
+
+#let _builtin-graph-value(alignment, sequence, col, metric) = {
+ let residue = _upper(sequence.at("aligned").slice(col, col + 1))
+ if (metric == "hydrophobicity" or metric == "molweight" or metric == "charge") and (residue == "." or residue == "-" or residue == "") {
+ return none
+ }
+ if metric == "hydrophobicity" {
+ return _hydropathy-scale.at(residue, default: 0.0)
+ }
+ if metric == "molweight" {
+ return _molweight-scale.at(residue, default: 100.0)
+ }
+ if metric == "charge" {
+ return _charge-scale.at(residue, default: 0.0)
+ }
+ if metric == "conservation" {
+ return _graph-conservation(alignment, col)
+ }
+ if metric == "entropy" {
+ return _graph-entropy(alignment, col)
+ }
+ if metric == "gap-fraction" or metric == "gaps" {
+ return _graph-gap-fraction(alignment, col)
+ }
+ if metric == "coverage" {
+ return _graph-coverage(alignment, col)
+ }
+ if metric == "identity" or metric == "identity-to-reference" {
+ return _graph-identity(alignment, sequence, col)
+ }
+ none
+}
+
+#let _parse-inline-graph-data(text, stacked: false) = {
+ let source = str(text).trim()
+ if stacked {
+ let rows = ()
+ for hit in source.matches(regex("\\(([^\\)]*)\\)")) {
+ let values = ()
+ for part in hit.captures.at(0).split(",") {
+ values.push(_parse-number(part))
+ }
+ rows.push((position: none, values: values))
+ }
+ return rows
+ }
+ let values = ()
+ for part in source.split(",") {
+ values.push(_parse-number(part))
+ }
+ values.map(value => (position: none, values: (value,)))
+}
+
+#let _parse-graph-line(line, stacked: false) = {
+ let trimmed = str(line).trim()
+ if trimmed == "" {
+ return none
+ }
+ let first = trimmed.slice(0, 1)
+ if first.matches(regex("[A-Za-z]")).len() > 0 and not trimmed.starts-with("NaN") and not trimmed.starts-with("nan") {
+ return none
+ }
+ let position = none
+ let body = trimmed
+ let numbered = trimmed.matches(regex("^(-?\\d+)\\s*:\\s*(.+)$"))
+ if numbered.len() > 0 {
+ position = int(numbered.first().captures.at(0))
+ body = numbered.first().captures.at(1)
+ }
+ let values = ()
+ for part in body.split(",") {
+ values.push(_parse-number(part))
+ }
+ if not stacked and values.len() > 1 {
+ values = (values.first(),)
+ }
+ (position: position, values: values)
+}
+
+#let _read-graph-data(source, stacked: false) = {
+ let rows = ()
+ for line in _split-lines(_source-text(source)) {
+ let parsed = _parse-graph-line(line, stacked: stacked)
+ if parsed != none {
+ rows.push(parsed)
+ }
+ }
+ rows
+}
+
+#let _read-frustr-data(source) = {
+ let rows = ()
+ for line in _split-lines(_source-text(source)) {
+ let trimmed = str(line).trim()
+ if trimmed == "" or trimmed.starts-with("#") {
+ continue
+ }
+ let parts = trimmed.split().filter(part => part != "")
+ if parts.len() < 9 {
+ continue
+ }
+ let residue = _parse-number(parts.at(0))
+ if residue == none {
+ continue
+ }
+ let rel-high = _parse-number(parts.at(6))
+ let rel-neutral = _parse-number(parts.at(7))
+ let rel-min = _parse-number(parts.at(8))
+ rows.push((position: int(residue), values: (rel-min, rel-neutral, rel-high)))
+ }
+ rows
+}
+
+#let _graph-data-source(parsed) = {
+ let metric = parsed.at("metric")
+ if type(metric) == str and _builtin-graph-metrics.contains(metric) {
+ return "builtin"
+ }
+ if type(metric) == bytes or _source-is-path(metric) {
+ return if parsed.at("kind") == "frustratometer" { "frustr" } else { "source" }
+ }
+ if parsed.at("kind") == "frustratometer" {
+ return "frustr"
+ }
+ if type(metric) == str and metric.contains("\n") {
+ return "source"
+ }
+ if type(metric) == str and parsed.at("kind") == "stackedbars" and metric.contains("(") {
+ return "inline"
+ }
+ if type(metric) == str and parsed.at("kind") != "stackedbars" and metric.contains(",") {
+ return "inline"
+ }
+ "source"
+}
+
+#let _graph-series(parsed, alignment, sequence, selected) = {
+ let source = _graph-data-source(parsed)
+ let stacked = _graph-is-stacked(parsed)
+ if source == "builtin" {
+ let out = ()
+ for col in selected {
+ if stacked {
+ out.push((_builtin-graph-value(alignment, sequence, col, parsed.at("metric")),))
+ } else {
+ out.push(_builtin-graph-value(alignment, sequence, col, parsed.at("metric")))
+ }
+ }
+ return out
+ }
+ let rows = if source == "inline" {
+ _parse-inline-graph-data(parsed.at("metric"), stacked: stacked)
+ } else if source == "frustr" {
+ _read-frustr-data(parsed.at("metric"))
+ } else {
+ _read-graph-data(parsed.at("metric"), stacked: stacked)
+ }
+ let numbered = rows.any(row => row.at("position") != none)
+ let out = ()
+ if numbered {
+ let lookup = (:)
+ for row in rows {
+ if row.at("position") != none {
+ lookup.insert(str(row.at("position")), row.at("values"))
+ }
+ }
+ for col in selected {
+ let pos = sequence.at("positions").at(col)
+ let values = if pos == none or not lookup.keys().contains(str(pos)) { () } else { lookup.at(str(pos)) }
+ if stacked {
+ out.push(values)
+ } else {
+ out.push(if values.len() > 0 { values.first() } else { none })
+ }
+ }
+ } else {
+ for idx in range(0, selected.len()) {
+ if idx < rows.len() {
+ let values = rows.at(idx).at("values")
+ out.push(if stacked { values } else { if values.len() > 0 { values.first() } else { none } })
+ } else {
+ out.push(if stacked { () } else { none })
+ }
+ }
+ }
+ out
+}
+
+#let _graph-normalized(bounds, value) = {
+ if value == none {
+ return none
+ }
+ let lo = bounds.at(0)
+ let hi = bounds.at(1)
+ if hi == lo {
+ return 0.0
+ }
+ calc.max(0.0, calc.min(1.0, (value - lo) / (hi - lo)))
+}
+
+#let _graph-stretch(config, kind, position) = {
+ let table = if kind == "color" { config.at("color-scale-stretch") } else { config.at("bar-graph-stretch") }
+ table.at(position, default: table.at("default"))
+}
+
+#let _graph-bar-colors(parsed, value) = {
+ let options = parsed.at("options")
+ let fg = if options.len() >= 1 and options.at(0) != "" {
+ options.at(0)
+ } else if parsed.at("metric") == "charge" and value < 0 {
+ "BrickRed"
+ } else if parsed.at("metric") == "charge" and value > 0 {
+ "RoyalBlue"
+ } else {
+ "Gray60"
+ }
+ let bg = if options.len() >= 2 and options.at(1) != "" { options.at(1) } else { none }
+ (fg: fg, bg: bg)
+}
+
+#let _graph-color-scale(parsed) = {
+ let options = parsed.at("options")
+ if options.len() >= 1 and options.at(0) != "" {
+ options.at(0)
+ } else if parsed.at("metric") == "charge" {
+ "RedBlue"
+ } else if parsed.at("metric") == "conservation" {
+ "ColdHot"
+ } else if parsed.at("metric") == "entropy" or parsed.at("metric") == "gap-fraction" or parsed.at("metric") == "gaps" {
+ "WhiteBlack"
+ } else if parsed.at("metric") == "coverage" or parsed.at("metric") == "identity" or parsed.at("metric") == "identity-to-reference" {
+ "ColdHot"
+ } else {
+ "WhiteBlack"
+ }
+}
+
+#let _stack-colors(parsed, count) = {
+ let options = parsed.at("options")
+ if parsed.at("kind") == "frustratometer" {
+ if options.len() >= 3 {
+ return (colors: (resolve-color(options.at(0)), resolve-color(options.at(1)), resolve-color(options.at(2))), background: none)
+ }
+ return (colors: (resolve-color("PineGreen"), resolve-color("Gray60"), resolve-color("BrickRed")), background: none)
+ }
+ if options.len() == 0 {
+ let out = ()
+ for idx in range(0, count) {
+ let level = if count <= 1 { 50 } else { calc.round(idx * 100 / (count - 1)) }
+ out.push(scale-color("BlueRed", level))
+ }
+ return (colors: out, background: none)
+ }
+ if _graph-scales.contains(options.first()) {
+ let out = ()
+ for idx in range(0, count) {
+ let level = if count <= 1 { 50 } else { calc.round(idx * 100 / (count - 1)) }
+ out.push(scale-color(options.first(), level))
+ }
+ let background = if options.len() >= 2 and options.at(1) != "" { options.at(1) } else { none }
+ return (colors: out, background: background)
+ }
+ let out = ()
+ for idx in range(0, count) {
+ out.push(resolve-color(options.at(calc.min(idx, options.len() - 1))))
+ }
+ (colors: out, background: none)
+}
+
+#let _graph-resolution(parsed, config, position) = if parsed.at("kind") == "color" {
+ calc.max(1, int(calc.ceil(_graph-stretch(config, "color", position))))
+} else {
+ calc.max(1, int(calc.ceil(8 * _graph-stretch(config, "bar", position))))
+}
+
+#let _graph-fill-cell(fill, background: none, frame: none) = (
+ char: "",
+ fg: "Black",
+ bg: fill,
+ emph: false,
+ frame: frame,
+)
+
+#let _graph-color-rows(parsed, config, series, selected, segment, position) = {
+ let rows = ()
+ let levels = ()
+ let bounds = _graph-range-from-style(parsed, series: series)
+ for value in series {
+ levels.push(_graph-normalized(bounds, value))
+ }
+ let repeat = _graph-resolution(parsed, config, position)
+ for _ in range(0, repeat) {
+ let cells = ()
+ for col in segment {
+ let idx = _array-position(selected, col)
+ if idx == none {
+ cells.push(_empty-cell())
+ } else {
+ let level = levels.at(idx)
+ if level == none {
+ cells.push(_empty-cell())
+ } else {
+ cells.push(_graph-fill-cell(scale-color(_graph-color-scale(parsed), calc.round(level * 100)), frame: if parsed.at("metric") == "conservation" { resolve-color("Gray40") } else { none }))
+ }
+ }
+ }
+ rows.push(cells)
+ }
+ rows
+}
+
+#let _bar-segment(level, lo, hi) = level >= lo and level < hi
+
+#let _graph-bar-rows(parsed, config, series, selected, segment, position) = {
+ let rows = ()
+ let bounds = _graph-range-from-style(parsed, series: series)
+ let baseline = if bounds.at(0) < 0 and bounds.at(1) > 0 { _graph-normalized(bounds, 0.0) } else if bounds.at(1) <= 0 { 1.0 } else { 0.0 }
+ let repeat = _graph-resolution(parsed, config, position)
+ let bg = if parsed.at("options").len() >= 2 and parsed.at("options").at(1) != "" { parsed.at("options").at(1) } else { none }
+ for row in range(0, repeat) {
+ let level = 1.0 - row / repeat
+ let cells = ()
+ for col in segment {
+ let idx = _array-position(selected, col)
+ if idx == none {
+ cells.push(_empty-cell())
+ } else {
+ let value = series.at(idx)
+ let normalized = _graph-normalized(bounds, value)
+ if normalized == none {
+ cells.push(_empty-cell())
+ } else {
+ let start = calc.min(normalized, baseline)
+ let stop = calc.max(normalized, baseline)
+ if _bar-segment(level, start, stop + 1.0 / repeat) {
+ let colors = _graph-bar-colors(parsed, value)
+ cells.push(_graph-fill-cell(resolve-color(colors.at("fg")), background: colors.at("bg")))
+ } else if bg != none {
+ cells.push(_graph-fill-cell(resolve-color(bg)))
+ } else {
+ cells.push(_empty-cell())
+ }
+ }
+ }
+ }
+ rows.push(cells)
+ }
+ rows
+}
+
+#let _graph-stacked-rows(parsed, config, series, selected, segment, position) = {
+ let rows = ()
+ let bounds = _graph-range-from-style(parsed, series: series)
+ let baseline = if bounds.at(0) < 0 and bounds.at(1) > 0 { _graph-normalized(bounds, 0.0) } else if bounds.at(1) <= 0 { 1.0 } else { 0.0 }
+ let max-count = 0
+ for values in series {
+ if values.len() > max-count {
+ max-count = values.len()
+ }
+ }
+ let palette = _stack-colors(parsed, max-count)
+ let repeat = _graph-resolution(parsed, config, position)
+ for row in range(0, repeat) {
+ let level = 1.0 - row / repeat
+ let cells = ()
+ for col in segment {
+ let idx = _array-position(selected, col)
+ if idx == none {
+ cells.push(_empty-cell())
+ } else {
+ let values = series.at(idx)
+ let segments = ()
+ let pos-cum = 0.0
+ let neg-cum = 0.0
+ for (value-index, value) in values.enumerate() {
+ if value == none {
+ continue
+ }
+ if value >= 0 {
+ let start = _graph-normalized(bounds, pos-cum)
+ pos-cum += value
+ let stop = _graph-normalized(bounds, pos-cum)
+ segments.push((lo: start, hi: stop, color: palette.at("colors").at(calc.min(value-index, palette.at("colors").len() - 1))))
+ } else {
+ let start = _graph-normalized(bounds, neg-cum)
+ neg-cum += value
+ let stop = _graph-normalized(bounds, neg-cum)
+ segments.push((lo: stop, hi: start, color: palette.at("colors").at(calc.min(value-index, palette.at("colors").len() - 1))))
+ }
+ }
+ let fill = none
+ for segment-data in segments {
+ if _bar-segment(level, segment-data.at("lo"), segment-data.at("hi") + 1.0 / repeat) {
+ fill = segment-data.at("color")
+ }
+ }
+ if fill != none {
+ cells.push(_graph-fill-cell(fill))
+ } else if palette.at("background") != none {
+ cells.push(_graph-fill-cell(resolve-color(palette.at("background"))))
+ } else if baseline != none and calc.abs(level - baseline) <= 1.0 / repeat {
+ cells.push(_graph-fill-cell(resolve-color("Gray30")))
+ } else {
+ cells.push(_empty-cell())
+ }
+ }
+ }
+ rows.push(cells)
+ }
+ rows
+}
+
+#let _graph-rows(alignment, config, sequence, selected, segment, style, position: none) = {
+ let parsed = _graph-style(style)
+ if parsed == none {
+ return ()
+ }
+ let series = _graph-series(parsed, alignment, sequence, selected)
+ if parsed.at("kind") == "color" {
+ return _graph-color-rows(parsed, config, series, selected, segment, position)
+ } else if parsed.at("kind") == "stackedbars" or parsed.at("kind") == "frustratometer" {
+ return _graph-stacked-rows(parsed, config, series, selected, segment, position)
+ } else {
+ return _graph-bar-rows(parsed, config, series, selected, segment, position)
+ }
+}
diff --git a/packages/preview/typshade/0.1.3/internal/render/logos.typ b/packages/preview/typshade/0.1.3/internal/render/logos.typ
new file mode 100644
index 0000000000..f03535504d
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/internal/render/logos.typ
@@ -0,0 +1,160 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "../model/logo.typ": _logo-column-items, _logo-max-bits, _logo-residue-color
+#import "../model/palette.typ": resolve-color
+#import "../model/parser.typ": _lower
+#import "../model/text-style.typ": _text-params, _text-string
+
+#let _logo-height(config) = 28pt * config.at("sequence-logo").at("stretch")
+
+#let _logo-residue(item, config, seq-type, subfamily) = {
+ let value = item.at("value")
+ let size = 5pt + 18pt * (calc.abs(value) / _logo-max-bits(seq-type))
+ let residue = if subfamily and value < 0 { _lower(item.at("residue")) } else { item.at("residue") }
+ text(.._text-params(config, "residues", fill: resolve-color(_logo-residue-color(config, seq-type, item.at("residue"), subfamily: subfamily)), size: size))[#_text-string(config, "residues", residue)]
+}
+
+#let _logo-stack(items, config, seq-type, subfamily, height, edge) = {
+ box(height: height, inset: 0pt)[#align(edge + center, stack(spacing: -1pt, ..items.map(item => _logo-residue(item, config, seq-type, subfamily))))]
+}
+
+#let _logo-scale-content(config, seq-type, subfamily: false) = {
+ let max-bits = calc.round(_logo-max-bits(seq-type) * 10) / 10
+ let logo-scale-color = resolve-color(config.at("logo-scale").at("color"))
+ let height = _logo-height(config)
+ let negative = subfamily and config.at("subfamily-logo").at("show-negatives")
+ if not negative {
+ return box(height: height, inset: 0pt)[#align(center + horizon, stack(
+ spacing: 1pt,
+ text(.._text-params(config, "ruler", fill: logo-scale-color, size: 6pt))[#str(max-bits)],
+ rect(width: 0.6pt, height: height - 14pt, fill: logo-scale-color),
+ text(.._text-params(config, "ruler", fill: logo-scale-color, size: 6pt))[0],
+ ))]
+ }
+ box(height: height + 12pt, inset: 0pt)[#align(center + horizon, stack(
+ spacing: 1pt,
+ text(.._text-params(config, "ruler", fill: logo-scale-color, size: 6pt))[#str(max-bits)],
+ rect(width: 0.6pt, height: height / 2 - 5pt, fill: logo-scale-color),
+ text(.._text-params(config, "ruler", fill: logo-scale-color, size: 6pt))[0],
+ rect(width: 0.6pt, height: height / 2 - 5pt, fill: logo-scale-color),
+ text(.._text-params(config, "ruler", fill: logo-scale-color, size: 6pt))[-#str(max-bits)],
+ ))]
+}
+
+#let _logo-name-cell(config, subfamily: false) = {
+ let height = _logo-height(config)
+ let label-color = resolve-color(config.at("legend").at("color"))
+ if subfamily and config.at("subfamily-logo").at("show-negatives") {
+ return box(height: height + 12pt, inset: 0pt)[#align(left + horizon, stack(
+ spacing: 1pt,
+ box(height: 6pt, inset: 0pt)[],
+ box(height: height / 2, inset: 0pt)[#align(left + bottom, text(.._text-params(config, "names", fill: label-color))[#_text-string(config, "names", config.at("subfamily-logo").at("name"))])],
+ box(height: 0pt, inset: 0pt)[],
+ box(height: height / 2, inset: 0pt)[#align(left + top, text(.._text-params(config, "names", fill: label-color))[#_text-string(config, "names", config.at("subfamily-logo").at("negative-name"))])],
+ box(height: 6pt, inset: 0pt)[],
+ ))]
+ }
+ let label = if subfamily { config.at("subfamily-logo").at("name") } else { config.at("sequence-logo").at("name") }
+ box(height: height, inset: 0pt)[#align(left + horizon, text(.._text-params(config, "names", fill: label-color))[#_text-string(config, "names", label)])]
+}
+
+#let _logo-column(alignment, config, col, subfamily: false) = {
+ let items = _logo-column-items(alignment, config, col, subfamily: subfamily)
+ if items.len() == 0 {
+ return box(height: if subfamily and config.at("subfamily-logo").at("show-negatives") { _logo-height(config) + 12pt } else { _logo-height(config) }, inset: 0pt)[]
+ }
+ let seq-type = alignment.at("seq-type")
+ if not subfamily {
+ return _logo-stack(items, config, seq-type, false, _logo-height(config), bottom)
+ }
+ let positive = items.filter(it => it.at("value") > 0)
+ let negative = if config.at("subfamily-logo").at("show-negatives") { items.filter(it => it.at("value") < 0) } else { () }
+ let pos-hit = config.at("relevance").at("show") and positive.any(it => it.at("value") >= config.at("relevance").at("threshold"))
+ let neg-hit = config.at("relevance").at("show") and negative.any(it => -it.at("value") >= config.at("relevance").at("threshold"))
+ box(height: _logo-height(config) + 12pt, inset: 0pt)[#align(center + horizon, stack(
+ spacing: 0pt,
+ box(height: 6pt, inset: 0pt)[#align(center + horizon, if pos-hit { text(.._text-params(config, "ruler", fill: resolve-color(config.at("relevance").at("color")), size: 6pt))[#_text-string(config, "ruler", config.at("relevance").at("char"))]} else { [] })],
+ _logo-stack(positive, config, seq-type, true, _logo-height(config) / 2, bottom),
+ rect(width: 80%, height: 0.35pt, fill: resolve-color(config.at("logo-scale").at("color"))),
+ _logo-stack(negative, config, seq-type, true, _logo-height(config) / 2, top),
+ box(height: 6pt, inset: 0pt)[#align(center + horizon, if neg-hit { text(.._text-params(config, "ruler", fill: resolve-color(config.at("relevance").at("color")), size: 6pt))[#_text-string(config, "ruler", config.at("relevance").at("char"))]} else { [] })],
+ ))]
+}
+
+#let _logo-table(alignment, config, segment, name-width, num-width, cell-width, subfamily: false) = {
+ let columns = ()
+ let items = ()
+ if config.at("names").at("show") and config.at("names").at("position") == "left" {
+ columns.push(name-width)
+ items.push(_logo-name-cell(config, subfamily: subfamily))
+ }
+ if config.at("numbering").at("show") and config.at("numbering").at("left") {
+ columns.push(num-width)
+ items.push([])
+ }
+ for col in segment {
+ columns.push(cell-width)
+ items.push(_logo-column(alignment, config, col, subfamily: subfamily))
+ }
+ if config.at("numbering").at("show") and config.at("numbering").at("right") {
+ columns.push(num-width)
+ items.push([])
+ }
+ if config.at("names").at("show") and config.at("names").at("position") == "right" {
+ columns.push(name-width)
+ items.push(_logo-name-cell(config, subfamily: subfamily))
+ }
+ table(columns: columns, inset: 0pt, stroke: none, align: center, column-gutter: 0pt, row-gutter: 0pt, ..items)
+}
+
+#let _logo-block(alignment, config, segment, name-width, num-width, cell-width, subfamily: false) = {
+ let columns = ()
+ let items = ()
+ let show-left = config.at("logo-scale").at("show") and (config.at("logo-scale").at("position") == "left" or config.at("logo-scale").at("position") == "leftright")
+ let show-right = config.at("logo-scale").at("show") and (config.at("logo-scale").at("position") == "right" or config.at("logo-scale").at("position") == "leftright")
+ if show-left {
+ columns.push(18pt)
+ items.push(_logo-scale-content(config, alignment.at("seq-type"), subfamily: subfamily))
+ }
+ columns.push(auto)
+ items.push(_logo-table(alignment, config, segment, name-width, num-width, cell-width, subfamily: subfamily))
+ if show-right {
+ columns.push(18pt)
+ items.push(_logo-scale-content(config, alignment.at("seq-type"), subfamily: subfamily))
+ }
+ table(columns: columns, inset: 0pt, stroke: none, column-gutter: 2pt, row-gutter: 0pt, ..items)
+}
+
+#let _legend-entry-table(config, items) = {
+ if items.len() == 0 {
+ return none
+ }
+ let cells = ()
+ for item in items {
+ cells.push(box(width: 10pt, height: 10pt, fill: resolve-color(item.at("bg")), stroke: none)[])
+ cells.push(text(.._text-params(config, "legend", fill: resolve-color(config.at("legend").at("color"))))[#_text-string(config, "legend", item.at("label"))])
+ }
+ move(dx: config.at("legend").at("dx"), dy: config.at("legend").at("dy"))[
+ #table(columns: (10pt, auto), inset: (x: 2pt, y: 1pt), stroke: none, column-gutter: 4pt, row-gutter: 2pt, ..cells)
+ ]
+}
+
+#let _legend-block(config, items: ()) = {
+ if not config.at("legend").at("show") {
+ return none
+ }
+ if config.at("shading").at("mode") == "functional" {
+ let option = config.at("shading").at("option", default: none)
+ let mode = if option == none { config.at("functional-default") } else { option }
+ if not config.at("functional-groups").keys().contains(mode) {
+ return none
+ }
+ let functional-items = ()
+ for group in config.at("functional-groups").at(mode) {
+ functional-items.push((label: group.at("name"), fg: group.at("fg"), bg: group.at("bg")))
+ }
+ return _legend-entry-table(config, functional-items)
+ }
+ _legend-entry-table(config, items)
+}
diff --git a/packages/preview/typshade/0.1.3/justfile b/packages/preview/typshade/0.1.3/justfile
new file mode 100644
index 0000000000..16f44b59bc
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/justfile
@@ -0,0 +1,64 @@
+TOML_FILE := "typst.toml"
+
+get_value_from_toml key:
+ #!/bin/bash
+ grep "{{key}}" {{TOML_FILE}} | sed -E 's/.*= "(.*)"/\1/'
+
+get_local_package_dir:
+ #!/bin/bash
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ # macOS
+ echo "$HOME/Library/Application Support/typst/packages"
+ elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
+ # Linux
+ echo "${XDG_DATA_HOME:-$HOME/.local/share}/typst/packages"
+ elif [[ "$OSTYPE" == "msys"* || "$OSTYPE" == "cygwin"* ]]; then
+ # Windows
+ echo "$APPDATA/typst/packages"
+ else
+ echo "Unsupported OS"
+ exit 1
+ fi
+
+install:
+ #!/bin/bash
+ if ! command -v typst &> /dev/null; then
+ echo "Error: Typst is not installed. Please install it before running this recipe."
+ exit 1
+ fi
+
+ PACKAGE_NAME=$(just get_value_from_toml name)
+ PACKAGE_VERSION=$(just get_value_from_toml version)
+ LOCAL_PACKAGE_DIR=$(just get_local_package_dir)
+ TARGET_DIR="$LOCAL_PACKAGE_DIR/local/$PACKAGE_NAME/$PACKAGE_VERSION"
+
+ mkdir -p "$TARGET_DIR"
+
+ echo "Copying files to $TARGET_DIR..."
+
+ cp {{TOML_FILE}} "$TARGET_DIR"
+
+ cp *.typ "$TARGET_DIR"
+
+ if [ -d "internal" ]; then
+ cp -r internal "$TARGET_DIR"
+ fi
+
+ [ -f "README.md" ] && cp README.md "$TARGET_DIR"
+ [ -f "LICENSE" ] && cp LICENSE "$TARGET_DIR"
+
+ echo "Package $PACKAGE_NAME version $PACKAGE_VERSION has been installed."
+
+clean:
+ #!/bin/bash
+ PACKAGE_NAME=$(just get_value_from_toml name)
+ PACKAGE_VERSION=$(just get_value_from_toml version)
+ LOCAL_PACKAGE_DIR=$(just get_local_package_dir)
+ TARGET_DIR="$LOCAL_PACKAGE_DIR/local/$PACKAGE_NAME/$PACKAGE_VERSION"
+
+ if [ -d "$TARGET_DIR" ]; then
+ rm -rf "$TARGET_DIR"
+ echo "Directory $TARGET_DIR has been removed."
+ else
+ echo "Directory $TARGET_DIR does not exist."
+ fi
\ No newline at end of file
diff --git a/packages/preview/typshade/0.1.3/lib.typ b/packages/preview/typshade/0.1.3/lib.typ
new file mode 100644
index 0000000000..e836668b9e
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/lib.typ
@@ -0,0 +1,15 @@
+// Copyright (C) 2026 Eito Yoneyama
+// SPDX-License-Identifier: GPL-2.0
+
+#import "internal/interface/annotations.typ": *
+#import "internal/interface/analysis.typ": *
+#import "internal/interface/controls.typ": *
+#import "internal/interface/data.typ": alignment-data, parse-alignment
+#import "internal/interface/inspect.typ": *
+#import "internal/interface/presets.typ": shade-preset, shade-theme, visual-theme
+#import "internal/interface/recipes.typ": *
+#import "internal/interface/selection.typ": *
+#import "internal/interface/shade.typ": *
+#import "internal/interface/shortcuts.typ": *
+#import "internal/interface/tracks.typ": *
+#import "internal/model/palette.typ": resolve-color, scale-color
diff --git a/packages/preview/typshade/0.1.3/typst.toml b/packages/preview/typshade/0.1.3/typst.toml
new file mode 100644
index 0000000000..7ff83ea8ab
--- /dev/null
+++ b/packages/preview/typshade/0.1.3/typst.toml
@@ -0,0 +1,11 @@
+[package]
+name = "typshade"
+version = "0.1.3"
+entrypoint = "lib.typ"
+authors = ["Eito Yoneyama"]
+license = "GPL-2.0-only"
+description = "Visualize multiple-sequence alignments for bioinformatics."
+compiler = "0.14.0"
+repository = "https://github.com/rice8y/typshade"
+keywords = ["bioinformatics", "alignment", "sequence", "visualization"]
+categories = ["visualization", "components"]