intern
Because your real interns have better things to do than align your ppt boxes.
intern is a rule-based linter for PowerPoint (.pptx) files. Point it at a deck
and it tells you exactly what’s wrong - misaligned boxes, inconsistent fonts, sloppy
text, duplicate titles - and can automatically fix alignment, font-size, and
whitespace problems.
Existing tools are proprietary Office add-ins or AI-powered web uploads. intern is the first open-source, rule-based CLI linter for PowerPoint: configurable, scriptable, and CI-friendly.
$ intern check quarterly.pptx
┌───────┬──────────────────────┬─────────┬────────────────┬──────┬───────────────────────────────────┬─────────────────────────────────────────┐
│ Slide ┆ Rule ┆ Type ┆ Position ┆ Id ┆ Text ┆ Message │
╞═══════╪══════════════════════╪═════════╪════════════════╪══════╪═══════════════════════════════════╪═════════════════════════════════════════╡
│ - ┆ FONT_SIZE_VARIETY ┆ - ┆ - ┆ - ┆ - ┆ 8 distinct body font sizes (limit: 3) │
│ ┆ ┆ ┆ ┆ ┆ ┆ │
│ 2 ┆ BULLET_LENGTH ┆ Body ┆ (40px, 132px) ┆ 5 ┆ Our goals for the next quarter... ┆ bullet is 26 words (20-word limit) │
│ 2 ┆ RIGHT_MARGIN ┆ Body ┆ (40px, 132px) ┆ 5 ┆ Our goals for the next quarter... ┆ right edge at 905.4px (typical 927.0px) │
│ ┆ ┆ ┆ ┆ ┆ ┆ │
│ 4 ┆ TITLE_TRAILING_PUNCT ┆ Title ┆ (28px, 15px) ┆ 12 ┆ Project Status. ┆ title ends with '.' - remove it │
│ ┆ ┆ ┆ ┆ ┆ ┆ │
│ 8 ┆ DUPLICATE_TITLE ┆ Title ┆ (28px, 15px) ┆ 44 ┆ Overview ┆ same title as slide 6 │
└───────┴──────────────────────┴─────────┴────────────────┴──────┴───────────────────────────────────┴─────────────────────────────────────────┘
5 violation(s) (5 error, 0 warning)
How it works
internunzips the.pptxand reads every slide’s shapes, text, and images.- Each rule inspects the deck and reports violations.
- Most violations carry a suggested fix - alignment, font sizes, and whitespace - and
intern fixapplies them in place.
Exit code is 0 when the deck is clean or has only warnings, and 1 when an
error-severity violation is found - so it drops straight into a CI pipeline.
Head to Installation to get started.
Installation
Pick whichever fits your setup - all three give you the same intern binary.
Homebrew - macOS & Linux
brew install markusz/intern/intern
Prebuilt binary
Download the archive for your platform from the
latest release, extract it, and
move intern onto your PATH.
| Platform | Archive |
|---|---|
| macOS (Apple Silicon) | intern-aarch64-apple-darwin.tar.gz |
| macOS (Intel) | intern-x86_64-apple-darwin.tar.gz |
| Linux (x86-64) | intern-x86_64-unknown-linux-gnu.tar.gz |
| Windows (x86-64) | intern-x86_64-pc-windows-msvc.zip |
One-liner for macOS/Linux (swap in the archive from the table):
curl -L https://github.com/markusz/intern/releases/latest/download/intern-aarch64-apple-darwin.tar.gz | tar xz
sudo mv intern /usr/local/bin/
Build from source
Requires Rust.
cargo install --path intern
Verify the install
intern --help
Command-line usage
intern has three subcommands: check reports violations, fix repairs the ones
it can, and ignore writes a suppression directive into a slide’s speaker notes.
check is the default action, so these two are equivalent:
intern deck.pptx
intern check deck.pptx
Every check and fix command accepts multiple files and directories - a directory
is expanded to the .pptx files directly inside it:
intern check slides/ extra.pptx
No configuration is required to get started.
intern check
Reads each presentation and prints its violations. Exits 0 when every deck is
clean or has only warnings, and 1 when an error-severity violation is found (see
severity).
| Flag | Default | Description |
|---|---|---|
--rules RULE_ID,... | all | Run only the specified rules |
--disable RULE_ID,... | none | Skip specific rules |
--threshold <px> | 2 | Alignment tolerance in pixels |
--slide <n> | all | Analyze only slide n (1-based) |
--output table|text|json | table | Output format |
--group-by slide|rule | slide | Group violations |
--config <path> | auto | Load settings from a specific file (Configuration) |
An unknown rule id passed to --rules or --disable is rejected with an error
rather than silently ignored.
intern fix
Applies the suggested fix for every fixable violation, writing each file in place.
The original is backed up next to it as <file>.bak.
| Flag | Default | Description |
|---|---|---|
--rules RULE_ID,... | all | Run only the specified rules |
--disable RULE_ID,... | none | Skip specific rules |
--threshold <px> | 2 | Alignment tolerance in pixels |
--slide <n> | all | Fix only slide n (1-based) |
--dry-run | off | Print what would change without writing |
Not every rule is auto-fixable. Alignment, font-size, and whitespace rules carry a concrete fix; the remaining text-quality and structural rules report the problem but leave the change to you. See the rules reference.
intern ignore
Writes a suppression directive into a slide’s speaker notes without opening
PowerPoint. Backs the original up to <file>.bak before writing.
intern ignore <file> -s <slide> -r <rule> [-e <element>]
| Flag | Description |
|---|---|
-s, --slide <n> | Slide number (1-based, matches the Slide column in check output) |
-r, --rule <ID> | Rule ID to suppress (e.g. EMPTY_TEXTBOX) |
-e, --element <id> | Element id (Id column) - omit to suppress the rule for the whole slide |
Suppress EMPTY_TEXTBOX for element 42 on slide 3:
intern ignore deck.pptx -s 3 -e 42 -r EMPTY_TEXTBOX
# appends: intern: disable(42) EMPTY_TEXTBOX
Suppress TITLE_Y for the whole of slide 1 (a title slide with a non-standard
layout):
intern ignore deck.pptx -s 1 -r TITLE_Y
# appends: intern: disable TITLE_Y
The rule ID is validated; an unknown ID is rejected with an error. If the slide
has no speaker-notes part, intern ignore creates one automatically.
Skipping checks
Skip an entire slide
To exclude a slide from every check - a title slide, a section divider, a deliberately different layout - add this line to its speaker notes:
intern: disable
To exclude it from only specific rules, list their ids:
intern: disable TITLE_Y, DUPLICATE_TITLE
Either way the slide is dropped before those rules run, so it affects neither the report nor their baselines (such as the median title position the other slides are compared against).
Skip a single element
The easiest way is intern ignore deck.pptx -s <slide> -r <rule> -e <element> -
it writes the directive automatically. To add it by hand, put the element id
(shown as Id in the table output) in parentheses:
intern: disable(42) EMPTY_TEXTBOX
Omit the rule list to suppress every rule for that element:
intern: disable(42)
Both syntaxes can appear on the same line as a slide-level directive:
intern: disable TITLE_Y
intern: disable(42) EMPTY_TEXTBOX
Multiple lines in the same slide’s speaker notes are all processed independently.
Use in CI
intern check exits 0 when every deck is clean and 1 when it finds an
error-severity violation - wire that exit code straight into a pipeline. Point it at
a directory to gate a whole folder of decks:
intern check slides/
JSON output is available for further processing:
intern check deck.pptx --output json > violations.json
Rules reference
intern ships 36 rules across four categories. Every rule has a stable id you
can pass to --rules or --disable, or configure under [rules.<RULE_ID>] in
.intern.toml.
Rules are either on (run by default) or off (must be opted in via
enabled = true in [rules.<RULE_ID>] or named in --rules). The status is
shown in the tables below.
Alignment
Geometric checks. All compare positions within a configurable pixel threshold.
Margin consistency (cross-slide)
Groups are treated as single units - only the group’s bounding box is considered, not its individual children.
| Rule | Status | What it catches | Default threshold |
|---|---|---|---|
LEFT_MARGIN | on | Slide’s leftmost unit is off the typical left margin | 10 px |
RIGHT_MARGIN | on | Slide’s rightmost unit right edge is off the typical right margin | 10 px |
BOTTOM_MARGIN | on | Content extends deeper than the typical bottom margin (overflow only) | 10 px |
TITLE_MARGIN | on | Gap between title and nearest content unit differs from the typical gap | 5 px |
Proximity alignment (per-slide)
| Rule | Status | What it catches | Default threshold |
|---|---|---|---|
CLOSE_X | on | Two units on the same slide have X positions within threshold - likely misaligned | 5 px |
CLOSE_Y | on | Two units on the same slide have Y positions within threshold - likely misaligned | 5 px |
Other alignment
| Rule | Status | What it catches |
|---|---|---|
TITLE_Y | on | Title top edge inconsistent across slides |
TITLE_X_WIDTH | on | Title left edge or width inconsistent across slides |
TEXT_ELEMENT_OVERLAP | on | Two text-bearing elements on the same slide have overlapping rects |
ELEMENT_OVERFLOW | on | Element extends outside the slide bounds |
Typography
| Rule | Status | What it catches | Limit |
|---|---|---|---|
TITLE_FONT_SIZE | on | Title font size differs from the majority | - |
FONT_SIZE_VARIETY | on | Too many distinct body font sizes across the deck | 3 sizes |
BODY_FONT_FAMILY | on | Body font family differs from the majority across slides | - |
BODY_TEXT_COLOR | off | Body text color differs from the majority across slides | - |
FONT_VARIETY | on | Too many distinct font families across the deck | 4 families |
COLOR_VARIETY | on | Too many distinct text colors across the deck | 6 colors |
BODY_TEXT_COLORis off by default: color varies intentionally in most decks (branded slides, dark backgrounds, highlighted callouts).
Text quality
| Rule | Status | What it catches | Limit |
|---|---|---|---|
DOUBLE_SPACE | on | Paragraph contains two or more consecutive spaces | - |
LEADING_SPACE | on | Paragraph starts with whitespace | - |
ALL_CAPS | off | Paragraph text is ALL CAPS | - |
REPEATED_WORD | on | Two consecutive identical words (“the the”) | - |
BULLET_CAPITALIZATION | on | Bullets have inconsistent first-letter capitalization | - |
BULLET_PUNCTUATION | on | Bullet ending punctuation is inconsistent across the deck | - |
BULLET_LENGTH | on | Bullet is too long | 20 words |
ALL_CAPSis off by default: common in corporate decks for KPI labels, callout boxes, and section stamps.
Structure
| Rule | Status | What it catches | Limit |
|---|---|---|---|
TITLE_PRESENT | off | Slide has no title element | - |
TITLE_LENGTH | on | Title is too long | 10 words |
TITLE_TRAILING_PUNCT | on | Title ends with . , : or ; | - |
DUPLICATE_TITLE | on | Title text is duplicated on another slide | - |
EMPTY_TEXTBOX | on | Text box has no text content | - |
SLIDE_COUNT | off | Deck has too many slides | 20 slides |
TITLE_PRESENTis off by default: section dividers and full-bleed image slides legitimately have no title element.
SLIDE_COUNTis off by default: the 20-slide limit is too deck-specific to be a useful default.
Auto-fixable rules
intern fix repairs the rules with an unambiguous correction - the alignment rules
(snap to the peer median), the font-size rules, and DOUBLE_SPACE / LEADING_SPACE
(normalise whitespace). The remaining text-quality and structural rules report the
problem but leave the wording to you.
Threshold
Geometric comparisons use EMU (English Metric Units). The default threshold for
most alignment rules is 2 px. The margin and proximity rules have their own
defaults (10 px for LEFT_MARGIN, RIGHT_MARGIN, BOTTOM_MARGIN; 5 px for
TITLE_MARGIN, CLOSE_X, CLOSE_Y). The global --threshold flag affects only
rules that use the 2 px default; per-rule overrides always win:
intern check deck.pptx --threshold 5
# in .intern.toml - overrides the per-rule default for that rule only
[rules.LEFT_MARGIN]
threshold = 15
The word- and count-based limits (TITLE_LENGTH, BULLET_LENGTH, FONT_VARIETY,
COLOR_VARIETY, SLIDE_COUNT) are tuned in each rule’s [rules.<RULE_ID>] table -
see Configuration.
Configuration
Settings can live in a TOML file so you don’t have to repeat flags on every run. Every field is optional, and CLI flags always override whatever the file provides.
Where intern looks
intern loads the first file it finds, in this order:
- the path passed to
--config <file> ./.intern.tomlin the current directory (project config)$XDG_CONFIG_HOME/intern.toml, or~/.config/intern.toml(user config)
If none exists, the built-in defaults apply. Files are not merged - the highest-precedence file wins as a whole, and CLI flags then layer on top of it.
A path passed to --config must exist, or intern exits with an error; the
auto-discovered files are used only when present.
Example
threshold_px = 2 # global alignment tolerance
disable = ["ALL_CAPS"] # turn rules off in bulk
# only = ["TITLE_Y", "TITLE_X_WIDTH"] # if set, ONLY these rules run
[output]
format = "table" # table | text | json
group_by = "rule" # slide | rule
[rules.TITLE_LENGTH]
max_words = 8
[rules.ALL_CAPS]
severity = "warning" # report it, but don't fail CI
[rules.TITLE_Y]
threshold = 1 # tighter tolerance, just for this rule
[rules.SLIDE_COUNT]
enabled = true # SLIDE_COUNT is off by default; enable it explicitly
max_slides = 40
Per-rule tables
Each rule can be configured in its own [rules.<RULE_ID>] table. A rule with no
table runs with default settings - except SLIDE_COUNT, which is off by default
(its slide limit is too deck-specific) and runs only when its table sets
enabled = true.
-
enabled = falseturns the rule off. -
severityis"error"(the default) or"warning".intern checkexits non-zero only when an error is found - warnings are reported but never fail CI. Demote noisy rules to"warning"to keep them advisory. -
thresholdoverrides the globalthreshold_pxfor that rule (alignment rules only) - e.g. pixel-perfect titles alongside a looser grid. -
The count-based rules take a limit:
Rule Key TITLE_LENGTHmax_wordsBULLET_LENGTHmax_wordsFONT_VARIETYmax_familiesCOLOR_VARIETYmax_colorsSLIDE_COUNTmax_slides
Blunt controls
disable- a top-level list that turns rules off in bulk.only- a top-level whitelist; when present, only the listed rules run.- Disabling always wins. If a rule is both whitelisted by
onlyand disabled (viadisableorenabled = false), it does not run andinternprints a warning.
Other settings
threshold_px- alignment tolerance in pixels for every geometric rule.[output]- defaultformat(table|text|json) andgroup_by(slide|rule) forintern check.
CLI flags override the file: --disable extends disable, and --rules replaces
only.
Examples
Recipes for common situations - find your task in the quick reference, jump to the recipe, copy the command. New to the command line? Start with First run.
Quick reference
| I want to… | Command |
|---|---|
| Check one deck | intern deck.pptx |
| Check every deck in a folder | intern check slides/ |
| Auto-fix what can be fixed | intern fix deck.pptx |
| Preview fixes without saving | intern fix deck.pptx --dry-run |
| Check a single slide | intern check deck.pptx --slide 4 |
| Turn a check off | intern check deck.pptx --disable ALL_CAPS |
| Run only certain checks | intern check deck.pptx --rules TITLE_Y |
| Be stricter or looser on alignment | intern check deck.pptx --threshold 1 |
| Get machine-readable output | intern check deck.pptx --output json |
First run
Never used a command-line tool? Three steps.
- Open a terminal. macOS: press
Cmd+Space, typeTerminal,Enter. Windows: openPowerShellfrom the Start menu. - Go to your deck’s folder. If it is on your Desktop:
cd Desktop - Check it:
intern quarterly.pptx
intern prints one row per problem:
┌───────┬──────────────────────┬─────────┬────────────────┬──────┬───────────────────────────────────┬─────────────────────────────────────────┐
│ Slide ┆ Rule ┆ Type ┆ Position ┆ Id ┆ Text ┆ Message │
╞═══════╪══════════════════════╪═════════╪════════════════╪══════╪═══════════════════════════════════╪═════════════════════════════════════════╡
│ 2 ┆ BULLET_LENGTH ┆ Body ┆ (40px, 132px) ┆ 5 ┆ Our goals for the next quarter... ┆ bullet is 26 words (20-word limit) │
│ 2 ┆ RIGHT_MARGIN ┆ Body ┆ (40px, 132px) ┆ 5 ┆ Our goals for the next quarter... ┆ right edge at 905.4px (typical 927.0px) │
│ ┆ ┆ ┆ ┆ ┆ ┆ │
│ 4 ┆ TITLE_TRAILING_PUNCT ┆ Title ┆ (28px, 15px) ┆ 12 ┆ Project Status. ┆ title ends with '.' - remove it │
└───────┴──────────────────────┴─────────┴────────────────┴──────┴───────────────────────────────────┴─────────────────────────────────────────┘
3 violation(s) (3 error, 0 warning)
- Slide - where the problem is (slide 1 is the first slide).
- Rule - the check that fired; look it up in the Rules reference.
- Type / Position / Id / Text - the offending element: its kind, top-left corner, shape id, and a short text excerpt. All
-for a whole-slide problem. - Message - what is wrong, in plain words.
A clean deck prints No violations found.
Everyday tasks
Check a deck
intern deck.pptx
check is the default action, so the subcommand is optional. Pass a folder to
check every .pptx inside it:
intern check slides/
Fix the easy problems
intern fix deck.pptx
Applies every fixable violation and saves a backup as deck.pptx.bak. Alignment,
font-size, and whitespace issues are corrected; wording problems are reported for
you to handle. The Rules reference marks which is which.
Preview before fixing
intern fix deck.pptx --dry-run
Lists what fix would change without touching the file.
Focus on one slide
intern check deck.pptx --slide 4
Slides count from 1. Deck-wide checks (like “all titles line up”) need every
slide, so drop --slide for the full picture.
Skip a slide that is intentionally different
Section dividers, the title slide, a deliberately unique layout - exclude a slide by adding a line to its speaker notes:
intern: disable # skip every rule on this slide
intern: disable TITLE_Y, DUPLICATE_TITLE # skip only these rules
The slide is dropped before those rules run, so it skews no baselines either.
Loosen or tighten alignment
intern check deck.pptx --threshold 5 # more forgiving
intern check deck.pptx --threshold 1 # pixel-perfect
Alignment tolerance in pixels (default 2). Anything off by less is treated as
fine.
Turn rules on or off
intern check deck.pptx --disable ALL_CAPS,TITLE_TRAILING_PUNCT # skip these
intern check deck.pptx --rules TITLE_Y,TITLE_X_WIDTH # run only these
Rule ids come from the Rules reference; a typo is rejected with an error. To make settings permanent, use a config file (below).
Teams & CI
A shared team standard
Commit an .intern.toml to your project - everyone who runs intern there picks
it up:
threshold_px = 3
disable = ["ALL_CAPS"]
[rules.TITLE_LENGTH]
max_words = 8
Full reference: Configuration.
Make a rule advisory instead of blocking
Every rule is an error by default and fails CI. To keep a rule’s findings visible
without failing the build, set its severity to warning:
[rules.ALL_CAPS]
severity = "warning"
intern check exits non-zero only when an error-severity violation exists.
Your personal defaults
Settings you want on every deck you lint, regardless of project, go in
~/.config/intern.toml. A project’s .intern.toml wins over it when both exist.
Gate a CI build
intern check exits 0 when clean (or only warnings) and 1 on an error-severity
violation - point it at a folder to gate every deck. GitHub Actions, saved as
.github/workflows/decks.yml:
name: decks
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install intern
run: |
curl -L https://github.com/markusz/intern/releases/latest/download/intern-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv intern /usr/local/bin/
- run: intern check slides/
Feed results to other tools
intern check deck.pptx --output json
JSON output nests violations under each file:
{
"files": [
{
"path": "deck.pptx",
"violations": [
{
"rule_id": "TITLE_Y",
"slide": 2,
"element_type": "Title",
"element_position": "(28px, 47px)",
"element_id": 12,
"excerpt": "Project Status",
"message": "title 34.2px lower than most slides",
"severity": "error"
}
]
}
]
}
Pipe it through jq - for example, list every
slide with a title-alignment problem:
intern check deck.pptx --output json \
| jq -r '.files[].violations[] | select(.rule_id == "TITLE_Y") | .slide'
Using the library
intern-core is the engine without the CLI - use it to build custom tooling,
reporting pipelines, or editor integrations.
[dependencies]
intern-core = { git = "https://github.com/markusz/intern" }
Checking a presentation
#![allow(unused)]
fn main() {
use intern_core::{
model::EMU_PER_PX,
reader::read_presentation,
rules::{all_rules, Limits, RuleContext},
};
let pres = read_presentation("deck.pptx")?;
let ctx = RuleContext {
threshold: 2 * EMU_PER_PX,
slide_width: pres.slide_width,
slide_height: pres.slide_height,
};
let limits = Limits { slide_count: 30, ..Limits::default() };
let violations: Vec<_> = all_rules(&limits)
.iter()
.flat_map(|rule| rule.check(&pres.slides, &ctx))
.collect();
for v in &violations {
println!("{:?} - {}", v.rule_id, v.message);
}
}
Key types
reader::read_presentation- parses a.pptxinto aPresentation(itsslidesplus the deck’s slide dimensions).rules::all_rules- builds every rule, parameterised byLimits.rules::Rule- the trait each rule implements:check(slides, ctx)takes a&[SlideData]and a&RuleContextand returns aVec<Violation>.rules::Violation- carries the rule id, slide, element, a structuredViolationMessage, and an optionalFix.writer::apply_fixes- applies a slice ofFixvalues to a.pptxin place.
Geometry is measured in EMU (English Metric Units); EMU_PER_PX converts a pixel
tolerance into the threshold the rules expect.