Create Theme From Site
Given a name (e.g. beeznest2) and a source — either a website URL
(e.g. https://beeznest.com) or a local path to a logo image
(e.g. /home/user/logos/acme.svg) — create a new Chamilo ColorTheme,
activate it as the platform's default theme, and attach a logo — all through
the same API the admin Vue UI (/admin/configuration/colors) uses. No direct
database or filesystem writes for the theme itself: every step from Step 5
onward is a real HTTP call against the running Chamilo instance, which is
what keeps this safe and correct (it reuses tested code paths — entity
validation, slug generation, colors.css rendering, image sanitization —
instead of reinventing them).
Step 0: Confirm inputs and scope, and detect the mode
Ask for whichever of these are missing:
- Theme name → becomes the
ColorTheme.title; the theme's folderslugis auto-generated from it server-side (Gedmo slug) — never guess or derive the slug yourself, always read it back from the API response. - Source → either a website URL, or a local path to a logo image file.
- Activate immediately? Setting a theme "active" makes it the platform's default look for every user on this access URL, immediately. Default to yes only if the user has clearly asked for it (as in "set it as default theme"); otherwise create the theme and ask before activating.
Detect which mode applies — don't ask the user to specify it, just check the source string itself:
if [[ "$SOURCE" =~ ^https?:// ]]; then
MODE=site
elif [[ -f "$SOURCE" ]] && file --mime-type -b "$SOURCE" | grep -q '^image/'; then
MODE=logo
else
echo "Not a URL and not a readable local image file: $SOURCE"
fi
sitemode → follow Steps 1–3 as written below (scrape the site's CSS for brand colors, then locate its logo separately).logomode → skip Steps 1–3 entirely and follow Step 1–3 (logo mode) just below instead: the given file supplies both the colors and the logo. Then continue at Step 4 as normal — everything from there on is identical regardless of mode.
Step 1–3 (logo mode): derive colors and logo from a local image file
Use this instead of Steps 1–3 when MODE=logo.
Extract the dominant colors
If the file is an SVG, try the cheap, exact route first — grep the source for literal fill colors, which are the real authored brand hex values with no quantization or anti-aliasing noise to filter out:
grep -oE '(fill|stop-color)\s*[:=]\s*"?#[0-9A-Fa-f]{6}' logo.svg | grep -oE '#[0-9A-Fa-f]{6}' | sort | uniq -c | sort -rn
If that yields nothing usable (no literal hex fills — e.g. the SVG uses
currentColor or CSS classes instead), or the file is a raster format
(PNG/JPG/WebP), fall back to a histogram of the rasterized image. Flatten
onto an implausible fill color first (bright magenta) so transparent-
background pixels are unambiguously distinguishable from genuine logo pixels
— including any real white, black, or near-white/black elements the logo
itself actually draws, which must not be discarded as "background":
convert logo.svg -background "#FF00FF" -flatten -resize 150x150 -colors 12 -depth 8 histogram:info:- \
| sed -E 's/^\s*([0-9]+):.*(#[0-9A-Fa-f]{6}).*/\1 \2/' \
| grep -vi '#FF00FF' \
| sort -rn
(convert rasterizes SVGs automatically via its delegate library — the same
command works unchanged for PNG/JPG input.)
Either way, you now have a frequency-ranked list of count #RRGGBB pairs.
Map them the same way Step 2 would, minus the "grep usage context" part
(there's no CSS to check a selector against here — frequency in the logo
itself is the signal):
- primary — the most frequent genuine color (excluding the magenta background marker).
- secondary — the next most frequent, clearly distinct color.
- tertiary — a third distinct color if the logo has one; if the logo is
genuinely two-tone, fall back to a dark neutral (e.g.
35 35 35) rather than forcing a weak third color into the role.
Continue to Step 4 using these three as the primary/secondary base colors (same downstream harmonization of success/info/warning/danger applies unchanged).
Prepare the logo
The given file is the logo — there's no site to search. Apply the same sizing rule as Step 3's site-mode logo handling:
identify logo.png # check WxH (skip for SVG — no fixed raster size)
convert logo.png -resize 190x60 logo_header.png # only if it exceeds 190x60
If the source file is an SVG, use it directly for header_svg/email_svg,
and additionally rasterize a PNG rendition for header_png/email_png
(convert logo.svg logo.png, then resize per above if needed) — Step 8
uploads whichever fields you have.
Step 1 (site mode): Fetch the source site
curl -sL -A "Mozilla/5.0 (compatible; ChamiloThemeBot/1.0)" "<url>" -o home.html
Extract every same-origin <link rel="stylesheet"> href from home.html and
fetch each one. Skip (or heavily discount) generic CDN/vendor stylesheets
(Bootstrap CDN builds, Google Fonts, common carousel/slider libraries) — they
inject well-known framework-default colors (Bootstrap's stock
#0d6efd/#dc3545/#198754/#ffc107/#0dcaf0/#6c757d) that will
otherwise masquerade as "brand colors" if you just count frequency.
Step 2 (site mode): Extract the real brand colors
- Collect every
#rrggbb(andrgb()/rgba()) color across the fetched CSS, tally frequency, and shortlist the top ~20. - For each shortlisted color, grep its actual usage context (the selector
and property it appears in) — don't trust raw frequency alone. A color is
a genuine brand signal when it shows up on: link hover states, button/CTA
backgrounds, active nav states, headline accents/underlines. A color is
framework noise when it only appears in unmodified
.btn-primary,.btn-success, etc. rule blocks that match Bootstrap's own stock hex values verbatim. - Pick:
- primary — the single most consistently-used deliberate accent color (CTAs, hovers, active states). This is almost always obvious once vendor noise is filtered out.
- secondary — a second deliberate accent if one exists, otherwise the site's own custom neutral/text-link color (not a generic Bootstrap gray).
- tertiary — the site's actual body text color (often a dark gray
close to, but distinct from,
#212529).
- Do not fully replace
success/info/warning/dangerwith site colors. Those carry meaning (green=good, red=bad) independent of brand identity, so their hue family must never change — but they should still feel like part of the same palette asprimary/secondaryrather than looking bolted on. Step 4 covers the harmonization (a small, capped hue nudge + a partial saturation blend toward the site's own primary/ secondary) that keeps each one clearly still "green"/"blue"/"yellow"/ "red" while relating to the brand.
Step 3 (site mode): Find the logo
Check, in this order, stopping at the first hit:
- JSON-LD
"logo"field (<script type="application/ld+json">containing"@type":"Organization"). - An
<img>near the header/nav whoseclass,alt, orsrccontains "logo" or "brand" (case-insensitive) — grephome.htmlfor this. - Common conventional paths on the same origin:
/logo.svg,/logo.png,/assets/logo.svg,/images/logo.png. - Open Graph
og:image— usually a generic share image, not the nav logo, so treat this as a last resort only. apple-touch-icon/ favicon, as an absolute last resort (a square icon, not a wordmark, but better than nothing).
Download whatever you find. If it's SVG, also try to get a PNG rendition
(many sites serve both a logo.svg and a responsive PNG version — check
near the same <img> tags). If only a raster logo is available and its
dimensions exceed the platform's header limit, resize it:
identify logo.png # check WxH
convert logo.png -resize 190x60 logo_header.png # only if it exceeds 190x60
email_png/email_svg have no dimension limit — use the original there.
Step 4: Map colors to the 23 ColorTheme variable keys
Values are strings of three space-separated integers "R G B" — no
#, no commas, no rgb() wrapper. *-gradient values are computed as each
RGB channel × 0.75 (rounded down) — a plain, explainable 25% darken, applied
to whatever the final base color is (site-derived for primary/secondary/
tertiary, harmonized for the four semantic colors below). (Chamilo's own
shipped defaults use inconsistent, hand-tuned offsets, some of them
out-of-range/negative — that's tolerated, since nothing validates these
values, but there's no need to replicate that exact formula.)
4a. Harmonize the semantic colors (success/info/warning/danger)
Don't hardcode Chamilo's defaults verbatim, and don't replace them with raw
site colors either — nudge them: a small, capped hue rotation toward the
site's own primary/secondary hue, plus a partial blend of saturation, using
basic color-wheel harmony (analogous-hue nudging). This keeps every one of
them unmistakably still green/blue/yellow/red while making the whole palette
read as one family instead of "brand colors + unrelated stock colors bolted
on". Run this script with the primary-base and secondary-base RGB you picked
in Step 2 (before formatting them as "R G B" strings):
cat > /tmp/harmonize_semantic_colors.py << 'EOF'
#!/usr/bin/env python3
"""
Nudge Chamilo's default semantic colors (success/info/warning/danger) toward
a site's derived primary/secondary brand colors, without losing their
semantic hue family (green stays green, red stays red, etc).
Usage: harmonize_semantic_colors.py PR PG PB SR SG SB
PR PG PB = primary-base RGB (0-255 each)
SR SG SB = secondary-base RGB (0-255 each)
Prints the 4 harmonized base colors as "R G B" strings.
"""
import colorsys
import math
import sys
DEFAULTS = {
"success": (119, 170, 12),
"info": (13, 123, 253),
"warning": (245, 206, 1),
"danger": (223, 59, 59),
}
MAX_HUE_SHIFT_DEG = 12 # cap: never enough to cross into a different semantic hue family
SATURATION_BLEND = 0.25 # how much of the site's own saturation profile to mix in
MIN_SATURATION = 0.45 # floor: keep semantic colors from washing out to gray
WEAK_CHROMA_THRESHOLD = 0.12 # below this combined chroma, treat brand hue as undefined
def rgb_to_hsl(rgb):
r, g, b = rgb
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
return h * 360, s, l
def hsl_to_rgb(h_deg, s, l):
r, g, b = colorsys.hls_to_rgb((h_deg % 360) / 360, l, s)
return tuple(max(0, min(255, round(c * 255))) for c in (r, g, b))
def shift_hue_toward(h0_deg, href_deg, max_shift_deg=MAX_HUE_SHIFT_DEG):
diff = (href_deg - h0_deg + 180) % 360 - 180
shift = max(-max_shift_deg, min(max_shift_deg, diff))
return (h0_deg + shift) % 360
def reference_hue_and_saturation(primary_rgb, secondary_rgb):
hp, sp, _ = rgb_to_hsl(primary_rgb)
hs, ss, _ = rgb_to_hsl(secondary_rgb)
# Saturation-weighted circular mean: a near-gray color has no meaningful
# hue, so it must not skew the reference hue (a naive unweighted average
# would wrongly pull a vivid primary's hue toward an arbitrary "hue" that
# colorsys assigns to gray).
x = sp * math.cos(math.radians(hp)) + ss * math.cos(math.radians(hs))
y = sp * math.sin(math.radians(hp)) + ss * math.sin(math.radians(hs))
chroma_strength = math.hypot(x, y)
href = math.degrees(math.atan2(y, x)) % 360 if chroma_strength >= WEAK_CHROMA_THRESHOLD else None
sref = (sp + ss) / 2
return href, sref
def harmonize(primary_rgb, secondary_rgb):
href, sref = reference_hue_and_saturation(primary_rgb, secondary_rgb)
out = {}
for name, rgb in DEFAULTS.items():
h0, s0, l0 = rgb_to_hsl(rgb)
new_h = shift_hue_toward(h0, href) if href is not None else h0
new_s = max(MIN_SATURATION, s0 * (1 - SATURATION_BLEND) + sref * SATURATION_BLEND)
out[name] = hsl_to_rgb(new_h, new_s, l0)
return out
if __name__ == "__main__":
pr, pg, pb, sr, sg, sb = (int(x) for x in sys.argv[1:7])
result = harmonize((pr, pg, pb), (sr, sg, sb))
for name, (r, g, b) in result.items():
print(f"{name} {r} {g} {b}")
EOF
python3 /tmp/harmonize_semantic_colors.py <primary_r> <primary_g> <primary_b> <secondary_r> <secondary_g> <secondary_b>
Lightness is deliberately left unchanged (only hue and saturation move), so the existing button-text contrast pairing (white on success/info/danger, black on warning) stays valid — no need to recompute those.
4b. Assemble the full variable set
{
"title": "<name>",
"variables": {
"--color-primary-base": "R G B",
"--color-primary-gradient": "R*0.75 G*0.75 B*0.75",
"--color-primary-button-text": "<same as primary-base>",
"--color-primary-button-alternative-text": "255 255 255",
"--color-secondary-base": "R G B",
"--color-secondary-gradient": "R*0.75 G*0.75 B*0.75",
"--color-secondary-button-text": "255 255 255",
"--color-tertiary-base": "R G B",
"--color-tertiary-gradient": "R*0.75 G*0.75 B*0.75",
"--color-tertiary-button-text": "<same as tertiary-base>",
"--color-success-base": "<harmonized success, from 4a>",
"--color-success-gradient": "<harmonized success * 0.75>",
"--color-success-button-text": "255 255 255",
"--color-info-base": "<harmonized info, from 4a>",
"--color-info-gradient": "<harmonized info * 0.75>",
"--color-info-button-text": "255 255 255",
"--color-warning-base": "<harmonized warning, from 4a>",
"--color-warning-gradient": "<harmonized warning * 0.75>",
"--color-warning-button-text": "0 0 0",
"--color-danger-base": "<harmonized danger, from 4a>",
"--color-danger-gradient": "<harmonized danger * 0.75>",
"--color-danger-button-text": "255 255 255",
"--color-form-base": "<same as primary-base>"
}
}
primary/tertiary button-text reuse their own base color (Chamilo's
convention for outline/ghost-style buttons); secondary-button-text and
primary-button-alternative-text are white (used on solid-color buttons).
Semantic button-text values are unchanged from Chamilo's defaults (see
4a — lightness never moves, so the existing contrast pairing still holds).
Step 5: Authenticate
Every remaining call requires ROLE_ADMIN.
TOKEN=$(curl -s http://<host>/api/authentication_token \
-X POST -H "Content-Type: application/json" \
-d '{"username":"admin","password":"<password>"}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['token'])")
Use Authorization: Bearer $TOKEN on every call below.
Step 6: Create the theme
curl -s http://<host>/api/color_themes \
-X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/ld+json" \
--data-binary @theme.json
Capture the returned id and slug from the response — you need both for
the next steps. (ColorTheme has no GET/GetCollection operation; to list
existing themes later, GET /api/access_url_rel_color_themes instead —
each entry embeds its colorTheme.)
Creating the theme does not activate it — a link row is created
automatically for the current access URL, but with active: false.
Step 7: Activate it (only if the user wants it as default now)
curl -s http://<host>/api/access_url_rel_color_themes \
-X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/ld+json" \
-d "{\"colorTheme\": \"/api/color_themes/$ID\"}"
This deactivates whatever theme was previously active and activates this one. Idempotent — safe to call again.
Step 8: Upload the logo
curl -s "http://<host>/themes/$SLUG/logos" \
-X POST -H "Authorization: Bearer $TOKEN" \
-F "header_png=@logo_header.png;type=image/png" \
-F "email_png=@logo_email.png;type=image/png" \
-F "header_svg=@logo.svg;type=image/svg+xml" \
-F "email_svg=@logo.svg;type=image/svg+xml"
Only include the fields you actually have a file for. This endpoint always
responds 201 even when individual fields are rejected — inspect the
results object, not just the status code:
header_png/email_png: mime must beimage/png(or.pngextension), must passgetimagesize.header_pngonly is capped at ≤190×60 — resize before uploading if needed (Step 3).email_pnghas no size cap.header_svg/email_svg: mimeimage/svg+xml(or.svgextension), sanitized server-side. These are also gated by the platform settingeditor.enabled_support_svg— if it's off, expect"skipped"for these fields. That is not a failure; PNG already covers the logo. Don't try to work around it (it's a deliberate anti-XSS control).- Any other result value (
invalid_mime,invalid_image,invalid_dimensions_header_png) means that specific field was rejected; the other fields still get processed independently.
Step 9: Verify
curl -s "http://<host>/themes/$SLUG/colors.css" # the rendered palette
curl -s "http://<host>/themes/$SLUG/logo/header" # NOT logo/header_png — route is /logo/{header|email}
curl -s "http://<host>/api/access_url_rel_color_themes" -H "Authorization: Bearer $TOKEN" # confirm exactly one active:true
Step 10: Report back
Summarize for the user: theme name/slug, the three chosen colors with a
one-line justification for each (what element on the source site — or, in
logo mode, which part of the logo image — they came from), a brief note that
success/info/warning/danger were harmonized rather than left as stock
defaults or fully replaced, where the logo came from (site mode: which
lookup in Step 3 found it; logo mode: the given file path), whether it's now
active, and a link to /admin/configuration/colors so they can review/tweak
it visually.
Gotchas learned the hard way
- Stale OPcache after a cache:clear. If API calls suddenly start
returning a generic 500 error page right after any
cache:clear/cache:warmup, a long-running Apache/mod_php worker is very likely still serving the previous compiled container from PHP's OPcache. The fix is an Apache restart — but if the box hosts other unrelated vhosts, that briefly interrupts all of them, so ask before restarting rather than doing it unilaterally. var/cache/prod/<subdir>not writable by the webserver. If a request fails with"The directory ... is not writable"in the flash/error output, a cache subdirectory was likely created by a different OS user (e.g. a CLI session) than the one the webserver runs as. Fix ownership/ permissions on that specific subdirectory rather than the whole cache tree.- Don't guess the slug. It's derived from the title server-side; always
read it from the
POST /api/color_themesresponse. - Logo mode's histogram trick relies on
-background "#FF00FF"being a color the real logo doesn't use. It's an implausible pick for virtually any brand, but if the logo genuinely is magenta/pink-based, pick a different rare marker color (e.g.#00FF00) instead and adjust thegrep -vifilter to match — otherwise you'll silently discard a real brand color as if it were background. - Photographic or gradient-heavy "logos" aren't real logos. If the given local image looks like a photo or a busy illustration rather than a wordmark/icon (many distinct colors, no small set of 2–4 dominant ones), the histogram approach will surface arbitrary noise instead of brand colors. Flag this to the user rather than silently proceeding — ask them to confirm the file is actually meant to be a logo.
convertneeds an SVG rasterization delegate (usuallylibrsvg) to handle.svginput. If it's missing,convert logo.svg ...fails or produces a blank raster. The SVG-sourcegrepfor literal fill colors doesn't need it — prefer that path for SVGs when it yields results.