# Why the Sitefinity page editor jumps when you drag a widget The MVC page editor ships with a pile of drag and drop defects: - The whole page shifts the moment you pick a widget up, so your drop target moves out from under the cursor - Row, column and container all highlight at once, and nothing tells you which one takes the drop - The highlight strobes on and off as you cross zone boundaries - The thing following your pointer is a pale bar the width of the page instead of something widget shaped - Outlines stay on the canvas after the drop until you reload - Spacing in the editor doesn't match what publishes None of it is your template's fault. It's the default behavior of the Telerik RadDock controls the editor is built on, controls that have not been meaningfully touched since MVC widgets shipped. All of it is fixable. Most from your own theme stylesheet, the rest from around 250 lines of javascript, because the strobing is a *timing* problem and a stylesheet can't put a delay on a class it doesn't own. What you end up with: the page holds still, zones fade in and out instead of strobing, the drag helper is a small chip under your cursor, outlines clear themselves, and editor spacing matches published spacing. Everything below assumes a plain MVC Sitefinity site on a stock Bootstrap 5 resource package. No custom widget framework, nothing exotic in the project. If you've got a Bootstrap 5 package and some `.row` and `.col-*` layouts, this applies to you. ## The page editor is not an iframe This trips up most people, and it matters a lot. Your page gets composed directly into the editor document. Which means two things at once. Your theme stylesheet is already loaded and already applying, so any CSS you write is live in the editor with no extra plumbing. And Sitefinity's editor markup is sitting in the same DOM as your markup, wrapping it. That second part is where nearly every problem below comes from. Telerik wraps every layout and every widget in extra nesting that has no counterpart on the published page: ```html
...widget 1...
...widget 2...
...
``` Watch what happens to the gap. On the live page that column has two flex children and spaces them 16px apart. In the editor it has exactly ONE flex child, the `.RadDockZone`, and your widgets are grandchildren. The gap has nothing left to space. So your carefully spaced column renders flush in the editor, an author looks at it, reasonably decides the spacing is broken, and starts "fixing" it. Bootstrap's own row gutters survive this, because `.g-4` puts padding on the `.col-*` itself and the injected zone sits inside that padding. Anything that spaces children instead of padding them breaks: `d-flex flex-column gap-3` and friends, plus any custom grid of your own built on `gap`. ## Give yourself a way to win specificity Before you write any fixes, add a class of your choosing to `` in your page template. Call it whatever you like, I'm using `mytheme` below. Or hang an id on your outer wrapper if that suits your markup better. It just needs to exist. Sitefinity's own editor rules are mostly three classes deep and they are NOT marked `!important`. One extra class on your selector outranks them cleanly and you don't start an `!important` war you'll regret in six months: ```css /* Sitefinity: .sfPageContainer .RadDockZone { ... } = 0,3,0 Yours: body.mytheme.sfPageEditor ... .RadDockZone = 0,4,0 */ body.mytheme.sfPageEditor .sfPageContainer .RadDockZone { margin-bottom: 0; } ``` `sfPageEditor` is a class Sitefinity puts on `` in the editor, so that's how you scope anything to editor-only. A few of the fixes below do genuinely need `!important` because Sitefinity marked its own rule that way, and I've called those out. ## Putting back the spacing the editor ate Since the injected `.RadDockZone` swallows your `gap`, you have to put the spacing back manually between the docks. Sibling margin is what works: ```css /* Restores the column gap that the injected zone ate. margin-top, not bottom, so nothing trails the last item. The placeholder is excluded at both ends; it collapses to zero height during a drag, and spacing a zero-height box reintroduces a jump. */ body.mytheme.sfPageEditor .sfPageContainer .gap-3 > .RadDockZone > .RadDock:not(.rdPlaceHolder) ~ .RadDock:not(.rdPlaceHolder) { margin-top: 1rem; } ``` Keying off the Bootstrap utility class means you write it once and every column using that utility is covered. If your layouts use a few different gap sizes, write one of these per size, or key off your own layout class instead. While you're in there, kill the spacing that goes the *other* way. Sitefinity puts a 12px bottom margin under every widget dock and another 12px under every empty drop zone. Neither one ships to the published page, so your authors are judging their spacing against 12px of padding that's about to vanish: ```css body.mytheme.sfPageEditor .sfPageContainer .zeControlDock, body.mytheme.sfPageEditor .sfPageContainer .RadDockZone { margin-bottom: 0; } ``` The widget title bars already separate the docks visually, so you lose nothing by removing it. ## The whole page jumps when you pick anything up This is the ugliest default in the editor and the cause genuinely surprised me. Telerik pre-renders a drop placeholder inside every single zone on the page. I counted 35 of them on one page, all sitting there `display: none` at rest. Start a drag and Telerik puts ALL of them into flow at once, then marks every one except the hovered zone's as `visibility: hidden`. Hidden, but still taking up space. So picking up one widget injects an invisible box into every other zone on the page simultaneously, and everything shifts under your cursor before you've really moved it. Collapse the resting placeholder to a true zero: ```css body.mytheme.sfPageEditor .sfPageContainer .RadDock.rdPlaceHolder { height: 0 !important; margin: 0 !important; border-width: 0 !important; /* Not cosmetic. The placeholder's own box is 0px here, but it holds a 14px "Drop here" label that escapes the zeroed box and grows the zone the instant Telerik flips display. Clipping it makes the collapsed placeholder contribute a true 0px. Measured: 13.5px down to 0. */ overflow: hidden; } ``` Notice what that rule does *not* touch: `display`. That's deliberate, and it's the trap. Telerik decides which placeholder is the live one by writing `display` inline from javascript. Override `display` with `!important` and you've taken that decision away from it, so now every zone on the page lights up at once. Zero out the box, leave `display` alone. Then bring the placeholder back on the zone that's actually the drop target: ```css body.mytheme.sfPageEditor .sfPageContainer .zePlaceholderHighlighted.sfCurrentDragZone:not(.zeDockZoneEmpty):not( :has(.zePlaceholderHighlighted) ) .RadDock.rdPlaceHolder { height: 58px !important; margin: 0 12px 1rem !important; border-width: 1px !important; } ``` That `:not(:has(.zePlaceholderHighlighted))` is doing real work, it isn't tidying. Sitefinity flags every ancestor zone up the chain with the same highlight classes, so without it the rule reads as "any placeholder anywhere under the drag" and restores all 35 again, which is the exact problem you just fixed. ## The 1px nudge on drag-approach Smaller one, but it makes the editor feel loose. Sitefinity ships these three rules, all `!important`: ```css .sfPageContainer .RadDockZone { padding: 1px } .sfPageContainer .RadDockZone.zePlaceholderHighlighted { padding: 0 } .sfPageContainer .zeDockZoneEmpty.zePlaceholderHighlighted { padding: 1px } ``` So drag-enter removes 1px of padding on all four sides and the zone's contents step outward. Reads like a border vanishing and everything getting slightly bigger. That third rule is Sitefinity trying to undo the second one, and it can never match. Sitefinity strips `.zeDockZoneEmpty` at the same moment it adds `.zePlaceholderHighlighted`, so those two classes are never on an element together. Put the padding back yourself, on every zone: ```css body.mytheme.sfPageEditor .sfPageContainer .RadDockZone.zePlaceholderHighlighted { padding: 1px !important; } ``` ### The `.zeDockZoneEmpty` trap That generalizes, and it's probably the most useful thing in this post, so it gets its own heading. `.zeDockZoneEmpty` is a *rest state* flag. Sitefinity removes it the instant a drag enters the zone and adds it back afterwards. So any styling you hang off it disappears at exactly the moment it was doing its job. I hit this three separate times before the pattern clicked. An empty-state height floor keyed on that class collapsed cards from 120px to 71px mid-drag, so everything below jumped under the cursor. Empty-zone styling vanished the second a drag arrived, leaving the placeholder fill flooding a white card. Test for emptiness by content instead. It survives Sitefinity's class swapping because it describes what's actually in the zone: ```css /* "holds no dock of its own that isn't the placeholder" */ .RadDockZone:not(:has(> .RadDock:not(.rdPlaceHolder))) ``` ## Stacked dashed outlines that never go away Sitefinity marks every zone up the ancestor chain with `.zePlaceholderHighlighted.sfCurrentDragZone`, so one drop target draws a stack of nested dashed outlines. On a Bootstrap layout that's usually three deep, container and row and column, all outlined at once. Sitefinity also never clears those flags after the drop, so the outlines just sit on the canvas until you reload. The placeholder fill already marks the live target, so the outlines are noise: ```css body.mytheme.sfPageEditor .sfPageContainer .zePlaceholderHighlighted, body.mytheme.sfPageEditor .sfPageContainer .sfCurrentDragZone { /* transparent, not `border: none`. The width has to stay, or every ancestor loses 2px on hover and the whole page shifts. */ border-color: transparent !important; } ``` Then bring the outline back on the one zone where it's actually useful, an empty leaf zone, using the content test from above instead of `.zeDockZoneEmpty`: ```css body.mytheme.sfPageEditor .sfPageContainer .RadDockZone.sfCurrentDragZone:not(:has(> .RadDock:not(.rdPlaceHolder))) { border-color: var(--bs-primary) !important; background-color: rgb(13 110 253 / 6%); } ``` Colors only here, never the border *width*. Rest state can be a fractional border width, so writing `1px` resizes the border box on drag-enter and shifts the page. Same reason the rule above says `transparent` instead of `border: none`. ## That page-wide bar you drag around When you drag a widget, the thing following your cursor should look like a widget. By default it's a pale bar spanning the full page width with a tiny label way over at the far left. There's no ghost and no clone involved here. Telerik drags the real element. On drag start it measures the dock, writes that measurement back as an inline `width`, bumps the z-index and reparents the live node, then adds a class to it. That inline width is the widget's full layout width, which is why you get a bar. An author `!important` rule outranks an inline style, so this is fixable. The `editor-dragging` class comes from the script in the next section: ```css body.mytheme.sfPageEditor.editor-dragging .RadDock.rdDragHelper { width: fit-content !important; max-width: 220px !important; min-width: 0 !important; height: auto !important; overflow: hidden; border: 1px solid var(--bs-border-color); border-radius: var(--bs-border-radius); background: var(--bs-body-bg); box-shadow: 0 1px 2px rgb(0 0 0 / 8%), 0 8px 20px -4px rgb(0 0 0 / 18%); } ``` Three things about that, and the third is why this section doesn't end here. The class name is `.rdDragHelper`, which I got by reading `RadDock.prototype._draggedCssClass` in the browser console on a live editor page. It is not documented anywhere. Guessing gets you `.rdDrag` or `.rdDockDragWrapper`, neither of which matches anything, and your rule just sits there dead. Check that property against your own Sitefinity version before you write the selector. You don't need to clean the overrides up. Telerik restores the inline width and z-index on drag end and removes the class, so all of it lapses on its own. And now your chip is in the wrong place. Telerik never stores your grab point. On mousedown the dragged element just keeps whatever offset it had from the cursor, and every mousemove applies a delta to wherever the element currently sits. That's invisible at full width, because the bar is under the pointer no matter where you grabbed it. Shrink an 800px title bar down to 100px though, and a grab at x=400 leaves the chip stranded 400px to the left of your pointer, still faithfully tracking your movements from over there. Fixing the width is what creates this, so the CSS above is only half the fix. Other half is `anchorToCursor` in the script. ## Why a stylesheet can't finish the job Everything so far has been CSS, and CSS runs out of road at four specific points. The strobing is a *timing* problem, not an appearance one. RadDock puts no delay on any of it. A zone lights on the very first hit-test frame that touches it and goes dark on the first frame that misses, so a cursor traveling across zone borders makes the highlight flicker. Then it gets worse. Lighting a populated zone inflates its placeholder by around 80px, which reflows everything below it and can slide a *different* zone under a stationary cursor, which re-triggers the whole thing. That's the feedback loop behind zones that seem to chase your pointer around, and no stylesheet can add a delay to a class it doesn't control. The drag-helper rule needs a hook. Telerik adds `.rdDragHelper` to the dock, but nothing on `body` says a drag is in progress, so there's no way to scope drag-only rules from CSS alone. The chip needs repositioning, which is the stranding problem from the last section. That's arithmetic against a live mouse event. And the placeholder height transition silently refuses to run. Telerik flips the placeholder from `display: none` to `block` and your class arrives in the same style recalculation, so the browser has no start value to interpolate from and jumps straight to the end. It needs a forced style flush in between, which is a javascript-only trick. So that's what the script is for. Four jobs, and none of them are clever: it debounces the un-highlight so a zone stays lit for a beat while your mouse settles, forces a style flush so the placeholder height actually animates instead of snapping, puts a class on `body` for the drag-only CSS to key off, and yanks the shrunken chip back under your cursor once at drag start. Note which edge gets the debounce. Lighting a zone is immediate, at `ENTER_DELAY_MS = 0`, because a delay there just reads as lag. It's the *un*-lighting that waits, 80ms, and re-entering inside that window cancels it outright. That's the whole trick behind zones that stop flickering when you drag across a boundary. It's around 250 lines, and it patches Telerik prototypes, which means it is going to look extremely deletable to whoever finds it next. Leave a header comment explaining why it's there. ```javascript (function () { 'use strict'; // Enter is immediate. A non-zero enter delay splits the drop feedback into a // staged three-beat pop, because zePlaceholderHighlighted is not just a // highlight: Sitefinity gates the "Drop here" label on it, our placeholder // inflate rule keys on it, and Telerik sets the placeholder's display from JS // synchronously. Delaying the class delays two of the three beats and leaves // the third at t=0. Tested at 100ms and it reads as laggy. var ENTER_DELAY_MS = 0; // The exit linger is the part that earns its keep. It stops the highlight // flickering off and on when the cursor grazes a zone boundary. It is also a // delay the user feels on every leave, and it stacks on the fade-out // transition, so keep the two together near 100ms total. var LEAVE_LINGER_MS = 80; var DRAGGING_CLASS = 'editor-dragging'; var CURSOR_OFFSET_PX = 10; // Defence in depth. Never let this run on a published page. function isPageEditor() { var body = document.body; if (!body) { return false; } return body.classList.contains('mytheme') && body.classList.contains('sfPageEditor'); } var patched = false; // Zones with a pending or applied highlight, so a drag end can flush them all. var trackedZones = []; function state(zone) { if (!zone._highlight) { zone._highlight = { enterTimer: null, leaveTimer: null, cssClass: null, lit: false }; } return zone._highlight; } function clearTimers(st) { if (st.enterTimer) { clearTimeout(st.enterTimer); st.enterTimer = null; } if (st.leaveTimer) { clearTimeout(st.leaveTimer); st.leaveTimer = null; } } // Runs fn with the zone's own class helpers shadowed, and returns the class // name the original code tried to add or remove. This is how only the // highlight gets deferred while everything else stays synchronous. function captureHighlightClass(zone, fn) { var captured = null; zone.addCssClass = function (cssClass) { captured = cssClass; }; zone.removeCssClass = function (cssClass) { captured = cssClass; }; try { fn(); } finally { delete zone.addCssClass; delete zone.removeCssClass; } return captured; } function light(zone, cssClass) { var st = state(zone); st.cssClass = cssClass; st.lit = true; // Load-bearing, not a stray debug line. Telerik's _showPlaceholder has just // flipped the placeholder from display:none to block; without a flush here // the height change lands in the same style recalc, the transition has no // start value to interpolate from, and it silently does not run. Reading // the placeholder's own computed height is what establishes that start // value. Reading document.body.offsetHeight also forces layout but does // not establish it, and cost 66ms on a full page. var placeholder = zone.get_element().querySelector(':scope > .RadDock.rdPlaceHolder'); if (placeholder) { void getComputedStyle(placeholder).height; } zone.addCssClass(cssClass); } function unlight(zone) { var st = state(zone); if (st.cssClass) { zone.removeCssClass(st.cssClass); } st.lit = false; } function scheduleEnter(zone, cssClass) { var st = state(zone); // Re-entering during the linger window is a no-op: the zone is still lit, // which is the whole point of a sticky target. clearTimers(st); if (trackedZones.indexOf(zone) === -1) { trackedZones.push(zone); } if (st.lit) { return; } // Zero means synchronous, not setTimeout(0). The highlight has to land in // the same frame as the placeholder Telerik just showed, and a 0ms timer is // still a separate macrotask and a separate paint. if (ENTER_DELAY_MS <= 0) { light(zone, cssClass); return; } st.enterTimer = setTimeout(function () { st.enterTimer = null; light(zone, cssClass); }, ENTER_DELAY_MS); } function scheduleLeave(zone) { var st = state(zone); clearTimers(st); // Left before the enter delay elapsed, so it never lit. Nothing to undo. if (!st.lit) { return; } st.leaveTimer = setTimeout(function () { st.leaveTimer = null; unlight(zone); }, LEAVE_LINGER_MS); } // A drag can end mid-linger. Without this the highlight stays on the page. function flushAll() { for (var i = 0; i < trackedZones.length; i++) { var zone = trackedZones[i]; clearTimers(state(zone)); unlight(zone); } trackedZones.length = 0; } function patchZoneHysteresis(zoneProto) { if (typeof zoneProto.dragEnterTarget !== 'function' || typeof zoneProto.dragLeaveTarget !== 'function') { return; } var originalEnter = zoneProto.dragEnterTarget; var originalLeave = zoneProto.dragLeaveTarget; zoneProto.dragEnterTarget = function () { var zone = this; var args = arguments; var cssClass = captureHighlightClass(zone, function () { originalEnter.apply(zone, args); }); if (cssClass) { scheduleEnter(zone, cssClass); } }; zoneProto.dragLeaveTarget = function () { var zone = this; var args = arguments; captureHighlightClass(zone, function () { originalLeave.apply(zone, args); }); scheduleLeave(zone); }; } // Re-anchors the shrunken chip to the pointer. Once, on drag start. // // Telerik's Draggable positions classically: each mousemove computes a delta // against the previous mouse position and applies it to wherever the element // currently is. It never recomputes an absolute position from the grab point, // so one correction here is preserved for the rest of the drag and the chip // tracks the pointer 1:1 from then on. That is why this does not need a // per-move handler, and why adding one would be strictly worse. // // Do not switch this to overwriting the Draggable's startPosition. That field // only drives the useTransformations branch, which this build does not take. function anchorToCursor(dock, args) { var domEvent = args && typeof args.get_domEvent === 'function' ? args.get_domEvent() : null; // jQuery-normalized events keep the native one on .originalEvent. var raw = domEvent && domEvent.originalEvent ? domEvent.originalEvent : domEvent; if (!raw) { return; } var pageX = raw.pageX; var pageY = raw.pageY; if (typeof pageX !== 'number' && raw.touches && raw.touches.length > 0) { pageX = raw.touches[0].pageX; pageY = raw.touches[0].pageY; } if (typeof pageX !== 'number' || typeof pageY !== 'number') { return; } // setLocation takes page coordinates and works out left/top against the // offset parent, which matters because _startDragDrop has just reparented // the dock to the form. if (!window.$telerik || typeof $telerik.setLocation !== 'function') { return; } $telerik.setLocation(dock.get_element(), { x: pageX + CURSOR_OFFSET_PX, y: pageY + CURSOR_OFFSET_PX }); } function patchDockDrag(dockProto) { if (typeof dockProto._dragStartHandler !== 'function' || typeof dockProto._dragEndHandler !== 'function') { return; } var originalStart = dockProto._dragStartHandler; var originalEnd = dockProto._dragEndHandler; dockProto._dragStartHandler = function (sender, args) { var result = originalStart.apply(this, arguments); // The original returns false when the drag is refused or cancelled. if (result !== false) { document.body.classList.add(DRAGGING_CLASS); anchorToCursor(this, args); } return result; }; dockProto._dragEndHandler = function () { try { return originalEnd.apply(this, arguments); } finally { document.body.classList.remove(DRAGGING_CLASS); flushAll(); } }; } function applyPatches() { if (patched || !isPageEditor()) { return; } var ui = window.Telerik && window.Telerik.Web && window.Telerik.Web.UI; if (!ui || !ui.RadDock || !ui.RadDockZone) { return; } patchZoneHysteresis(ui.RadDockZone.prototype); patchDockDrag(ui.RadDock.prototype); patched = true; } // RadControls load late and re-register after every WebForms partial // postback, so try on each load until the prototypes are actually there. if (window.Sys && window.Sys.Application) { Sys.Application.add_load(applyPatches); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', applyPatches); } else { applyPatches(); } })(); ``` Four things before you paste that in. The rule a future edit must not break is that the structural calls inside `dragEnterTarget` and `dragLeaveTarget`, meaning `_showPlaceholder` and `_hidePlaceholder` plus the enter and leave events, stay synchronous. Drop targeting and `canDrop` read that state, so deferring it breaks where widgets actually land. Only the highlight class gets deferred, and `captureHighlightClass` is how it manages that. It shadows the *instance's* `addCssClass` and `removeCssClass` for the duration of the original call, while the placeholder helpers reach for `Sys.UI.DomElement.addCssClass` directly and are unaffected. Verify that on your own build. Every patch is guarded. If a method goes missing after an upgrade the patch is skipped and you get default behavior rather than a broken editor. Which matters more than usual here, because these hooks came out of `Object.getOwnPropertyNames(Telerik.Web.UI.RadDockZone.prototype)` in the console, not out of documentation. Run that yourself if the script ever goes quiet. Registration happens on every `Sys.Application` load, not once on DOM ready. RadControls load late and re-register after WebForms partial postbacks, so a single early attempt finds no prototypes and silently does nothing. And `_dragEndHandler` wraps the original in `try`/`finally`. If the original throws, the body class and any lingering highlights still get cleaned up, instead of leaving the editor stuck in a permanent drag state until somebody reloads. ## Matching the CSS to the script With the timing handled in javascript, add a pair of transitions. Asymmetric on purpose: ```css /* Soft in, quick out. The base rule is the exit duration, because it is the computed style once the class comes off. A symmetric fade stacks on top of the 80ms leave linger and the highlight reads as hanging around after the cursor has gone. */ body.mytheme.sfPageEditor .sfPageContainer .RadDockZone { transition: background-color 80ms ease-out, border-color 80ms ease-out; } body.mytheme.sfPageEditor .sfPageContainer .RadDockZone.zePlaceholderHighlighted { transition: background-color 130ms ease-out, border-color 130ms ease-out; } @media (prefers-reduced-motion: reduce) { body.mytheme.sfPageEditor .sfPageContainer .RadDockZone { transition: none; } } ``` Animating layout during a drag is normally the thing you avoid. It's safe here for one specific reason: RadDock hit-tests only from raw mouse events, so layout moving under a stationary cursor cannot re-fire the hit test. No cursor motion, no feedback loop. ## Two small ones Sitefinity's widget title bars are `#f4f6f7`, which assumes a white canvas. Bootstrap's default `--bs-body-bg` is white so out of the box you may never notice. Set it to anything else, which most real sites do, and the bars all but vanish and the editor loses its structure. Pick a color that reads as a header band against both your canvas and your cards, and keep it lighter than the widget it labels. The title bar is chrome, it should sit behind the content it names. Fair warning, Sitefinity's border rule on those bars is six classes deep AND `!important`, so a short selector will silently apply your background while dropping your border. That's an odd-looking half-applied result that cost me a while to figure out. The other one is a single rule. Every toolbox tile and every placed widget carries a blue "MVC" badge drawn as `::after` content. On a busy page that's over a hundred of them, labeling an implementation detail no author will ever act on: ```css body.mytheme.sfPageEditor .sfMvcIcn::after { display: none; } ``` ## Worth an afternoon None of this touches how the page renders for visitors. Every rule is scoped behind `.sfPageEditor`, and the script won't patch anything unless it finds that class on `body`. Worst case is an upgrade renames a hook, the selector stops matching, and you're back to the default with nothing broken. Progress doesn't seem to want to spend the time on this editor, so I did it for them. If you have an MVC layout template, your authors are going to love this. And none of it is exotic. A drop placeholder sitting in all 35 zones, pushed into the layout the second you pick anything up, on a stock Bootstrap 5 package on day one. An 80ms delay on the leave is a one line change from inside the source. From a theme stylesheet it took 250. Verify every class name against your own editor rather than trusting a blog post, this one included. These are internal Telerik class names and prototype methods. They're stable enough to build on, they're not contractual, and one wrong name is the difference between a fix and a rule that silently does nothing. # Sitefinity search can't find the words inside your PDFs, PowerPoints or spreadsheets Search your site for a phrase you know is written inside a document and you get nothing back. Search the document's *title* and it comes up fine. Search the text *inside* it, zero results. So here's what's going on. When Sitefinity indexes a file for search it doesn't read the file the way you do, it hands the file off to a small piece of code called a text extractor whose only job is to open that file type and hand back the words as plain text. Those words go into the search index. If no extractor exists for a file type, or the extractor blows up, the file still gets indexed but with an empty body. Title searchable, contents invisible. And nothing tells you. No error in the admin UI, no failed upload, no document that looks broken. Search just quietly stops finding things. ## What Sitefinity actually ships Extractors for exactly five file types: PDF, DOCX, HTML, plain text and RTF. That's the whole list. So PowerPoint files, Excel files and macro-enabled Word files (`.docm`) have NEVER had their contents indexed on any Sitefinity site, out of the box. And a chunk of your PDFs are probably failing too, for a completely separate reason, even though PDF is on the supported list. Both are fixable because Sitefinity lets you register your own extractors through a config file. Four of them below plus the config that turns them on. If you just want the code, skip ahead. ### Signs this is your problem Search the Sitefinity error log for these. Any of them means documents are landing in the index with no text: - `MIME type is not supported` repeated once per document, per indexing pass. That's a file type with no extractor. - `InvalidStructureTreeException`, or a long Telerik stack trace mentioning `RichMedia`, on a PDF. That's the PDF problem below. - A reindex that runs forever and produces a searchable index where every document only matches on its filename. I found this the way most people probably do. A user insisted a document existed, search disagreed, and the document was sitting right there in the library. ## What actually goes wrong Two separate problems, and it took me a while to see they were separate. First one, whole formats have no extractor at all. PowerPoint is the big one. There is no PPTX extractor in Sitefinity, period, so in a library heavy with lecture slides a large fraction of the corpus is indexed title-only. Excel, same story. So is macro-enabled Word (`.docm`), which is more irritating because it's the same format as DOCX underneath. The stock extractor is registered against the DOCX MIME type, `.docm` announces a different one, so it just falls through. In the log that's your "MIME type is not supported" warning, once per document, per indexing pass. The second one is subtler because PDF *does* have a stock extractor and it still fails. Decompiling it explains why: ```csharp // Sitefinity's DefaultPdfTextExtractor, paraphrased RadFixedDocument document = provider.Import(doc, timeout); document.DocumentUnhandledException += (s, e) => { e.Handled = true; }; ``` Look at the order. The tolerant exception handler gets attached to the document *after* `Import()` has already returned. That handler catches problems during export, but a malformed structure tree or a `RichMedia` annotation throws during import, before there's a document to attach a handler to. So the handler does nothing. Symptom is an `InvalidStructureTreeException` stack trace in the log and a PDF in the index with an empty body. In a decade-old library of scanned handouts and PDFs with embedded video, that happens a LOT. ## ITextExtractor Sitefinity's extraction is properly pluggable, which is the only reason any of this is fixable. You write a class, name it in a config file, and Sitefinity starts calling it for that file type. `ITextExtractor` is three members: ```csharp public interface ITextExtractor { string MimeType { get; } void Initialize(string mimeType, NameValueCollection config); void GetText(Stream doc, Stream text); } ``` `GetText` reads the file from `doc` and writes plain text to `text`. That's the whole contract. Registration lives in `App_Data/Sitefinity/Configuration/DocumentServiceConfig.config`, and the factory instantiates your type by name through `Activator.CreateInstance`:
DocumentServiceConfig.configView on GitHub
<?xml version="1.0" encoding="utf-8"?>
<!--
  App_Data/Sitefinity/Configuration/DocumentServiceConfig.config

  Sitefinity MERGES these with its built-in registrations rather than replacing them, so listing
  only what you are adding or overriding is enough. Out of the box it registers exactly five mime
  types (pdf, docx, html, plain text, rtf); everything else indexes title-only.

  The type string is "Namespace.ClassName, AssemblyName" with no version or public key token.
  Replace "YourAssembly" with the assembly your extractors are compiled into.

  Two things that catch people out:

    1. Registering an extractor does NOTHING to documents already in the index. Deploy the
       assembly, add this config, then run a full reindex from
       Administration > Search indexes > (your index) > Reindex.

    2. There is one registration per mime type, not per extractor. PresentationDocument opens
       pptx, pptm and ppsx identically, but each mime type still needs its own line, and
       Sitefinity constructs a separate instance for each and tells it which mime it is via
       Initialize(mimeType, config).
-->
<documentServiceConfig>
  <extractorSettings>

    <!-- Overrides Sitefinity's DefaultPdfTextExtractor, which cannot open PDFs whose structure
         tree throws during import. Same mime type, so this replaces the built-in entry. -->
    <add mimeType="application/pdf"
         extractorType="Sitefinity.TextExtractors.PdfTextExtractor, YourAssembly" />

    <!-- PowerPoint: pptx, pptm, ppsx. No built-in extractor exists for any of these. -->
    <add mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation"
         extractorType="Sitefinity.TextExtractors.PptxTextExtractor, YourAssembly" />
    <add mimeType="application/vnd.ms-powerpoint.presentation.macroEnabled.12"
         extractorType="Sitefinity.TextExtractors.PptxTextExtractor, YourAssembly" />
    <add mimeType="application/vnd.openxmlformats-officedocument.presentationml.slideshow"
         extractorType="Sitefinity.TextExtractors.PptxTextExtractor, YourAssembly" />

    <!-- Excel: xlsx, xlsm, xltx. No built-in extractor exists for any of these. -->
    <add mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
         extractorType="Sitefinity.TextExtractors.XlsxTextExtractor, YourAssembly" />
    <add mimeType="application/vnd.ms-excel.sheet.macroEnabled.12"
         extractorType="Sitefinity.TextExtractors.XlsxTextExtractor, YourAssembly" />
    <add mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.template"
         extractorType="Sitefinity.TextExtractors.XlsxTextExtractor, YourAssembly" />

    <!-- Macro-enabled Word only. Plain .docx
         (application/vnd.openxmlformats-officedocument.wordprocessingml.document) already has a
         working built-in extractor: leave it alone. -->
    <add mimeType="application/vnd.ms-word.document.macroEnabled.12"
         extractorType="Sitefinity.TextExtractors.WordTextExtractor, YourAssembly" />

    <!--
      NOT registered, on purpose: the legacy binary formats.

        .ppt  application/vnd.ms-powerpoint
        .doc  application/msword
        .xls  application/vnd.ms-excel

      These are OLE2 compound files, not ZIP packages, so the OpenXML SDK cannot read them and
      pointing these extractors at them only produces errors. If you need them indexed, NPOI
      reads all three and would need its own ITextExtractor implementation.
    -->

  </extractorSettings>
</documentServiceConfig>
Worth knowing before you go hunting for it: this merges, it does not replace. Adding entries here leaves the stock DOCX, HTML, plain and RTF registrations intact. A MIME type you name explicitly gets overridden by yours, everything else stays as shipped. I checked that against a live instance rather than trusting the docs, and it behaves. You need one registration per MIME type even when a single class handles several, which is why PPTX, PPTM and PPSX all point at the same extractor. `Initialize` is where each instance learns which MIME type it got created for. The thing that catches people out: registering an extractor does NOTHING to documents already in the index. Sitefinity extracts text at index time, not at search time, so existing documents keep whatever body text they had (usually none) until you rebuild the index from the search settings screen. Deploy the DLL, add the config, then reindex. ## A shared helper for writing the text back out Every extractor ends the same way, turning a `StringBuilder` into UTF-8 bytes on the output stream. There's a trap in doing that the obvious way.
TextExtractorOutput.csView on GitHub
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

namespace Sitefinity.TextExtractors
{
    internal static class TextExtractorOutput
    {
        /// <summary>
        /// Diagnostic context for a failed extraction.
        ///
        /// signature vs mimeType is the whole diagnosis when a document will not open, because
        /// Sitefinity derives the mime from the file extension and never from the bytes. A
        /// document declared as xlsx whose signature reads "ole2" is a password-protected or
        /// legacy binary file, and no amount of code will make the OpenXML SDK read it.
        ///
        /// streamLength is the other half: compare it against the size your storage claims for
        /// the document. Equal means the stored file really is damaged. Smaller means something
        /// in your read path is truncating, which is a genuine bug worth chasing.
        /// </summary>
        internal static Dictionary<string, string> BuildContext(string mimeType, Stream doc)
        {
            var context = new Dictionary<string, string>
            {
                { "mimeType", mimeType ?? "null" },
                { "signature", FileSignature.Describe(doc) }
            };

            // Length throws on some stream implementations; a partial context beats losing it all
            try
            {
                context["streamLength"] = doc?.Length.ToString() ?? "null";
            }
            catch (Exception ex)
            {
                context["streamLength"] = ex.GetType().Name;
            }

            // The extractor only ever sees a stream, never the document it came from. If you
            // want the title and id in your reports, stash them in an AsyncLocal from your
            // inbound pipe before extraction and merge them in here. Without that you are
            // correlating error reports to documents by timestamp, which is miserable.
            return context;
        }

        internal static void WriteUtf8(StringBuilder builder, Stream text)
        {
            // Raw byte write rather than a StreamWriter: disposing a writer would close the
            // caller's output stream before Sitefinity's DocumentService reads it back, and the
            // resulting "cannot access a closed stream" is a confusing way to learn that.
            var bytes = Encoding.UTF8.GetBytes(builder.ToString());
            text.Write(bytes, 0, bytes.Length);
        }
    }
}
Wrap the output in `using (var writer = new StreamWriter(text))` and you close the stream you were handed. Sitefinity then reads back from a closed stream, you get an empty body, and there's nothing obvious pointing at the cause. Write the bytes directly and leave the stream's lifetime to whoever created it. ## Fixing PDFs that index with no text The whole point here is attaching the tolerant handler to `ImportSettings` so it's live *during* import, then skipping the structure tree entirely. Text extraction never needs the structure tree (it's an accessibility and reading-order artifact) so `IgnoreMarkedContent` costs you nothing and removes the most common failure surface in one line.
PdfTextExtractor.csView on GitHub
using System;
using System.Collections.Specialized;
using System.IO;
using System.Text;
using Telerik.Sitefinity.Services.Documents;
using Telerik.Windows.Documents.Fixed.FormatProviders;
using Telerik.Windows.Documents.Fixed.FormatProviders.Pdf;
using Telerik.Windows.Documents.Fixed.FormatProviders.Text;
using Telerik.Windows.Documents.Fixed.Model;

namespace Sitefinity.TextExtractors
{
    /// <summary>
    /// Replaces Sitefinity's DefaultPdfTextExtractor.
    ///
    /// THE BUG IN THE STOCK ONE: it attaches its exception-tolerant handler to the document
    /// AFTER Import() returns, which is too late for the failures that matter. Structure-tree
    /// problems (InvalidStructureTreeException) and RichMedia annotations throw DURING import,
    /// so the handler is never reached and the whole document indexes with no body text. On an
    /// older library that is hundreds of files.
    ///
    /// Two changes fix it:
    ///   - subscribe DocumentUnhandledException on ImportSettings, so it is live during import
    ///   - set IgnoreMarkedContent, which skips the structure tree altogether
    ///
    /// The structure tree is accessibility and reading-order metadata. Text extraction never
    /// needs it, so skipping it costs nothing and removes an entire class of failure.
    ///
    /// Uses Telerik Document Processing, which already ships with Sitefinity.
    /// </summary>
    public class PdfTextExtractor : ITextExtractor
    {
        public string MimeType { get; private set; }

        public void Initialize(string mimeType, NameValueCollection config)
        {
            this.MimeType = mimeType;
        }

        public void GetText(Stream doc, Stream text)
        {
            ExtractorGuard.Run(nameof(PdfTextExtractor), "ExtractText", () => TextExtractorOutput.BuildContext(this.MimeType, doc), () =>
            {
                this.Extract(doc, text);
            });
        }

        private void Extract(Stream doc, Stream text)
        {
            var provider = new PdfFormatProvider();

            // OnDemand parses pages lazily instead of materialising the whole document up front,
            // which matters when a reindex walks thousands of files
            provider.ImportSettings.ReadingMode = ReadingMode.OnDemand;
            provider.ImportSettings.IgnoreMarkedContent = true;

            // The line the stock extractor gets wrong. Subscribing HERE, on the settings rather
            // than the document, is what makes it active while Import is running.
            provider.ImportSettings.DocumentUnhandledException += (sender, e) =>
            {
                e.Handled = true;
            };

            // Mirrors the stock extractor's use of the documentServiceConfig timeout (minutes)
            var timeout = TimeSpan.FromMinutes(5);

            RadFixedDocument document = provider.Import(doc, timeout);
            if (document == null)
            {
                return;
            }

            // And again on the document, for anything thrown during export rather than import
            document.DocumentUnhandledException += (sender, e) =>
            {
                e.Handled = true;
            };

            var exporter = new TextFormatProvider();
            var settings = new TextFormatProviderSettings("\r\n", string.Empty);
            var extracted = exporter.Export(document, settings, timeout);

            // A scanned PDF is a picture of text with no text layer, so extraction "succeeds"
            // with an empty string and the document indexes title-only with no error anywhere.
            // This is the seam where OCR would go. The branch is live but the call is not, so
            // measuring how many of your documents are scans is a one-line change.
            var looksLikeAScan = document.Pages.Count > 0
                && extracted.Trim().Length < document.Pages.Count * 20;
            if (looksLikeAScan)
            {
                // extracted = OcrPages(document);
            }

            TextExtractorOutput.WriteUtf8(new StringBuilder(extracted), text);
        }

        // NOT WIRED UP. Sketch for adding OCR against an external service (Azure AI Document
        // Intelligence, AWS Textract, a Tesseract sidecar; all take an image and return text).
        // Read these three before enabling it:
        //
        //   1. Billed per page, and a full reindex reprocesses every document. Cache results
        //      keyed by document id or every rebuild costs real money.
        //   2. Seconds per page, on the indexing thread. A few hundred scans turns a reindex
        //      from minutes into hours. If this becomes real, the right shape is a background
        //      job writing into an indexed field, not inline extraction.
        //   3. OCR returns confidently wrong words on bad scans, and those become real search
        //      terms. Usually still better than an empty document, but it is not the same
        //      quality bar as a genuine text layer, and users cannot tell the difference.
        //
        // private string OcrPages(RadFixedDocument document)
        // {
        //     var builder = new StringBuilder();
        //     foreach (var page in document.Pages)
        //     {
        //         // Telerik can rasterise a page for you; the provider type has moved between
        //         // versions (Skia-based currently), so check what your assemblies ship.
        //         byte[] pageImage;
        //         using (var buffer = new MemoryStream())
        //         {
        //             // imageProvider.Export(page, buffer);
        //             pageImage = buffer.ToArray();
        //         }
        //
        //         // Keep the per-page timeout short; one stalled call holds the whole reindex.
        //         // builder.AppendLine(ocrClient.Recognize(pageImage));
        //     }
        //     return builder.ToString();
        // }
    }
}
Couple of notes if you're copying this. `ReadingMode` lives in `Telerik.Windows.Documents.Fixed.FormatProviders`, NOT the `.Pdf` namespace you'd expect, which is an easy twenty minutes lost to `CS0103`. And `ReadingMode.OnDemand` keeps page content lazy, which matters when the indexer is chewing through hundreds of files and you'd rather not hold every page of every PDF in memory at once. This does not turn every broken PDF into clean text. What it fixes is the population of structurally damaged but genuinely textual PDFs that the stock extractor throws away wholesale. A PDF that's purely scanned images is a different case, and the next bit covers where that would hook in. ### Where OCR would go, if you have a service for it Some PDFs have no text layer at all. Somebody scanned a paper handout, or printed to PDF from an image, and every page is a picture of words. There's nothing for `TextFormatProvider` to export, so extraction "succeeds" and returns an empty string. In the index that's indistinguishable from a file that failed. You can detect it cheaply though. If a PDF has pages but almost no extracted characters, it's an image-only document. That check is already in `PdfTextExtractor.cs` above, near the bottom. The branch is live, the `OcrPages` call inside it is commented out, so logging that branch tells you how big your scan problem actually is before you spend a penny on it. Before you wire that up though, none of these are code problems. It costs money per page, and a reindex processes every document again. A library with a few thousand scanned pages can turn one "rebuild the index" click into a real invoice. If you do this, cache the OCR result somewhere keyed by the document so a second reindex reads the cache instead of re-billing you. It's slow. OCR is seconds per page against milliseconds for normal extraction, and it runs inline on the indexing thread. A few hundred scanned documents can stretch a reindex from minutes into hours. Doing the OCR in a separate background job and writing the result into a field the indexer reads is the saner architecture, it's just a bigger build. And it's fallible in a way normal extraction isn't. OCR on a bad scan produces plausible-looking wrong words, which then land in your search index as real terms. Usually still better than an empty document, but "searchable" and "accurate" have come apart at that point. I left this as a hook rather than an implementation. The image-only PDFs in the library were a small enough slice that the cost and complexity weren't worth it yet, and the detection line above at least tells you how big that slice actually is if you log it. ## Indexing PowerPoint slides, including speaker notes No Telerik dependency needed here. The OpenXML SDK (`DocumentFormat.OpenXml`) already ships with Sitefinity so this is free. PPTX, PPTM and PPSX are all ZIP packages of XML, and `PresentationDocument.Open` reads all three. The useful trick is that all visible slide text lives in `a:t` elements no matter how deeply shapes, groups, tables and text boxes are nested. Walking `Descendants()` gets you everything without modeling the shape tree at all.
PptxTextExtractor.csView on GitHub
using System.Collections.Specialized;
using System.IO;
using System.Text;
using DocumentFormat.OpenXml.Packaging;
using Telerik.Sitefinity.Services.Documents;

namespace Sitefinity.TextExtractors
{
    /// <summary>
    /// Sitefinity ships no PowerPoint extractor at all, so pptx / pptm / ppsx documents index
    /// with a title and no body text. Uses the OpenXML SDK that already ships with Sitefinity,
    /// so there is no new dependency.
    ///
    /// Register one entry per mime type in DocumentServiceConfig.config; see registration.config.
    /// </summary>
    public class PptxTextExtractor : ITextExtractor
    {
        public string MimeType { get; private set; }

        public void Initialize(string mimeType, NameValueCollection config)
        {
            // Sitefinity constructs one instance per registered mime type and tells it which
            // one it is. PresentationDocument opens all three formats identically.
            this.MimeType = mimeType;
        }

        public void GetText(Stream doc, Stream text)
        {
            // A failure costs this document's body text only, never the indexed item
            ExtractorGuard.Run(nameof(PptxTextExtractor), "ExtractText", () => TextExtractorOutput.BuildContext(this.MimeType, doc), () =>
            {
                OpenXmlPackageReader.Read(doc, package => this.ExtractCore(package, text));
            });
        }

        /// <summary>
        /// Called by OpenXmlPackageReader, possibly twice: once on the original stream and, if
        /// the SDK refuses that, once on a repaired copy. Must therefore be safe to run twice.
        /// </summary>
        private void ExtractCore(Stream doc, Stream text)
        {
            var builder = new StringBuilder();

            // Deliberately not a using block. The SDK's close-time cleanup throws on a package
            // opened read-only, and that would discard the text collected just below it, so
            // disposal goes through DisposeQuietly instead.
            var presentation = PresentationDocument.Open(doc, false);
            try
            {
                var presentationPart = presentation.PresentationPart;
                if (presentationPart == null)
                {
                    return;
                }

                foreach (var slidePart in presentationPart.SlideParts)
                {
                    // Every piece of visible slide text is an a:t element, no matter how deeply
                    // it is nested in shapes, groups, tables or text boxes. Walking descendants
                    // gets all of it without modelling the shape tree at all.
                    foreach (var textNode in slidePart.Slide.Descendants<DocumentFormat.OpenXml.Drawing.Text>())
                    {
                        builder.AppendLine(textNode.Text);
                    }

                    // Speaker notes are often the most searchable text in the whole deck: the
                    // slide says "Management" over a diagram while the notes pane holds the
                    // actual prose somebody will search for months later.
                    var notes = slidePart.NotesSlidePart;
                    if (notes != null)
                    {
                        foreach (var noteText in notes.NotesSlide.Descendants<DocumentFormat.OpenXml.Drawing.Text>())
                        {
                            builder.AppendLine(noteText.Text);
                        }
                    }
                }
            }
            finally
            {
                OpenXmlPackageReader.DisposeQuietly(presentation);
            }

            TextExtractorOutput.WriteUtf8(builder, text);
        }
    }
}
Speaker notes deserve their own mention. A slide will often read "Management" over a diagram while the notes pane holds the actual prose, the drug names and the caveats and the sentence somebody is going to search for six months later. Indexing slides but skipping notes throws away the most searchable text in the file, and it's four extra lines to include it. ### The printer settings bug you'll hit in production Run this against a real document library and within a day or two you'll see this: ``` DocumentFormat.OpenXml.Packaging.OpenXmlPackageException: The document cannot be opened because there is an invalid part with an unexpected content type. [Part Uri=/ppt/printerSettings/printerSettings1.bin], [Content Type=application/vnd.openxmlformats-officedocument.presentationml.printerSettings], [Expected Content Type=application/vnd.openxmlformats-officedocument.spreadsheetml.printerSettings]. ``` Read the last two lines. The part's content type is correct. The SDK's *expectation* is wrong, it wants the spreadsheet printer-settings type inside a presentation. The OpenXML SDK build Sitefinity ships is 2.0.5022.0, the original 2008 release, and its content-type table maps every printer-settings part to the spreadsheet variant. So any deck saved by a copy of PowerPoint that recorded printer settings, which is most of them on an office-installed machine, refuses to open. Fixed in SDK 2.5+, but you can't swap that DLL out from under Sitefinity's own dependency. Printer settings carry no indexable text, so catch the failure, strip those parts out of an in-memory copy with `System.IO.Packaging` (already there, it's in WindowsBase), and retry:
OpenXmlPackageReader.csView on GitHub
using System;
using System.IO;
using DocumentFormat.OpenXml.Packaging;

namespace Sitefinity.TextExtractors
{
    /// <summary>
    /// Shared open policy for the three OOXML extractors (pptx, xlsx, docm).
    ///
    /// Every workaround for the OpenXML SDK lives here rather than in the extractors, because
    /// each extractor originally carried its own copy and the same gap then had to be found
    /// three separate times in production.
    ///
    /// The sequence is: reject input that is not a zip at all, try the straightforward open,
    /// and on the two known SDK failures retry against a repaired copy of the package.
    /// </summary>
    internal static class OpenXmlPackageReader
    {
        internal static void Read(Stream doc, Action<Stream> readPackage)
        {
            // Not a zip, so there is no OOXML package inside it. See the FileSignature header
            // for why a non-zip reaches an OOXML extractor in the first place.
            if (!FileSignature.IsZip(doc))
            {
                return;
            }

            try
            {
                readPackage(doc);
            }
            catch (FileFormatException)
            {
                // Starts with the zip magic but has no central directory, so the upload is
                // truncated or damaged. Unlike the SDK defects below there is nothing to strip
                // and retry: a zip with no directory cannot be read by anything. Skip it the
                // same way a non-zip is skipped, and let the document index on its title.
                //
                // This is a deliberate trade. Skipping silently means you can no longer see
                // WHICH files are damaged. If you would rather find and re-upload them, delete
                // this catch and let the guard report them (capped) instead.
                return;
            }
            catch (Exception ex) when (IsRecoverableOpenFailure(ex))
            {
                // The SDK refused the package. The known cause is printer-settings parts, so
                // retry against a copy with those removed.
                Stream sanitized = null;
                try
                {
                    sanitized = OpenXmlPackageSanitizer.StripPrinterSettings(doc);
                }
                catch
                {
                    // A package broken some other way makes the sanitizer throw on its own.
                    // Letting that escape would report the failed recovery instead of the
                    // actual fault, which is a much harder thing to diagnose later.
                }

                if (sanitized == null)
                {
                    throw;
                }

                using (sanitized)
                {
                    readPackage(sanitized);
                }
            }
        }

        /// <summary>
        /// Both exception types mean the same thing here: the SDK could not load this package,
        /// and a printerSettings-stripped copy is worth trying.
        /// </summary>
        private static bool IsRecoverableOpenFailure(Exception ex)
        {
            if (ex is OpenXmlPackageException)
            {
                return true;
            }

            // IOException looks unrelated and is not. When Load() fails, its own cleanup path
            // calls Close() -> DeleteUnusedDataPartOnClose() -> Package.DeletePart(), and that
            // throws IOException("Cannot modify a read-only container") because the package was
            // opened read-only. The cleanup failure REPLACES the OpenXmlPackageException that
            // caused it, so matching only on the latter means the retry below never runs and
            // you are left staring at a read-only error that explains nothing.
            return ex is IOException;
        }

        /// <summary>
        /// Disposes a package opened read-only, tolerating the same SDK cleanup defect.
        ///
        /// DeleteUnusedDataPartOnClose runs on EVERY dispose, not only failed loads, so a
        /// document that opened and extracted perfectly can still throw on the closing brace of
        /// a using block. Callers write their extracted text after disposing, so letting that
        /// through would silently discard work that already succeeded.
        /// </summary>
        internal static void DisposeQuietly(OpenXmlPackage package)
        {
            if (package == null)
            {
                return;
            }

            try
            {
                package.Dispose();
            }
            catch (IOException)
            {
                // Safe precisely because the package is read-only: there are no pending writes
                // to lose. The same swallow on a writable package would be a real bug.
            }
        }
    }
}
where `ExtractCore` is the extraction body from above, and the sanitizer rewinds the source stream, copies it, and removes the parts plus every relationship pointing at them:
OpenXmlPackageSanitizer.csView on GitHub
using System;
using System.IO;
using System.Linq;

namespace Sitefinity.TextExtractors
{
    /// <summary>
    /// Works around a defect in OpenXML SDK 2.0 (2.0.5022.0, the build Sitefinity ships).
    ///
    /// The SDK's content-type expectation table maps EVERY printerSettings part to the
    /// SPREADSHEET printer-settings content type. So a pptx or docm saved on a machine with a
    /// printer configured, which is most of them, fails to open with:
    ///
    ///     The document cannot be opened because there is an invalid part with an unexpected
    ///     content type. [Part Uri=/ppt/printerSettings/printerSettings1.bin] ...
    ///     [Expected Content Type=...spreadsheetml.printerSettings]
    ///
    /// The part is fine. The SDK's expectation is wrong. It is fixed in SDK 2.5+, but you
    /// generally cannot upgrade that assembly out from under Sitefinity's own dependency.
    ///
    /// Printer settings carry no indexable text, so the recovery is to hand back a copy of the
    /// package with those parts removed and let the caller retry.
    /// </summary>
    internal static class OpenXmlPackageSanitizer
    {
        /// <summary>
        /// Returns a seekable in-memory copy of the package with all printerSettings parts and
        /// the relationships pointing at them removed. Returns null when there is nothing to
        /// strip or the source cannot be re-read, in which case the caller should rethrow the
        /// original open failure rather than pretend it recovered.
        /// </summary>
        internal static Stream StripPrinterSettings(Stream doc)
        {
            // The failed open already consumed part of the stream. Without seek there is
            // nothing left to copy, so the caller keeps its original exception.
            if (doc == null || !doc.CanSeek)
            {
                return null;
            }
            doc.Seek(0, SeekOrigin.Begin);

            var working = new MemoryStream();
            doc.CopyTo(working);
            working.Seek(0, SeekOrigin.Begin);

            // System.IO.Packaging lives in WindowsBase and is fully qualified throughout this
            // file, because DocumentFormat.OpenXml.Packaging has colliding type names.
            using (var package = System.IO.Packaging.Package.Open(working, FileMode.Open, FileAccess.ReadWrite))
            {
                var printerParts = package.GetParts()
                    .Where(part => part.Uri.OriginalString.IndexOf("printerSettings", StringComparison.OrdinalIgnoreCase) >= 0)
                    .Select(part => part.Uri)
                    .ToList();

                if (printerParts.Count == 0)
                {
                    return null;
                }

                // A relationship pointing at a part that no longer exists fails validation just
                // like the bad part did, so the relationships go first.
                foreach (var part in package.GetParts().ToList())
                {
                    // Relationship parts cannot themselves carry relationships; asking throws.
                    if (part.Uri.OriginalString.EndsWith(".rels", StringComparison.OrdinalIgnoreCase))
                    {
                        continue;
                    }
                    foreach (var rel in part.GetRelationships().ToList())
                    {
                        if (!IsInternalTarget(rel))
                        {
                            continue;
                        }
                        var target = System.IO.Packaging.PackUriHelper.ResolvePartUri(part.Uri, rel.TargetUri);
                        if (printerParts.Contains(target))
                        {
                            part.DeleteRelationship(rel.Id);
                        }
                    }
                }

                foreach (var rel in package.GetRelationships().ToList())
                {
                    if (!IsInternalTarget(rel))
                    {
                        continue;
                    }
                    // PackageRootUri is a .NET Core addition; on Framework, resolve against "/".
                    var target = System.IO.Packaging.PackUriHelper.ResolvePartUri(new Uri("/", UriKind.Relative), rel.TargetUri);
                    if (printerParts.Contains(target))
                    {
                        package.DeleteRelationship(rel.Id);
                    }
                }

                foreach (var uri in printerParts)
                {
                    package.DeletePart(uri);
                }
            }

            // Package.Close disposes the working stream. MemoryStream.ToArray still reads after
            // dispose, so hand back a fresh stream over the finished bytes.
            return new MemoryStream(working.ToArray());
        }

        /// <summary>
        /// The single most important line in this file.
        ///
        /// An EXTERNAL relationship, which is what an ordinary hyperlink is, carries an absolute
        /// URI, and PackUriHelper.ResolvePartUri throws ArgumentException("Cannot be an absolute
        /// URI") on those. Without this guard the sanitizer throws on any document containing a
        /// link, the caller treats the package as unrecoverable, and the original error is
        /// rethrown as though no recovery had been attempted.
        ///
        /// This is easy to miss because a hand-built test package has no hyperlinks and passes.
        /// Real documents nearly always have them.
        /// </summary>
        private static bool IsInternalTarget(System.IO.Packaging.PackageRelationship relationship)
        {
            return relationship.TargetMode == System.IO.Packaging.TargetMode.Internal
                && !relationship.TargetUri.IsAbsoluteUri;
        }
    }
}
Two things in there are not obvious, and I only found them because real files behave differently than test files. `IsInternalTarget` exists because `PackUriHelper.ResolvePartUri` throws `ArgumentException("Cannot be an absolute URI")` the moment you hand it an external relationship, and an ordinary hyperlink IS an external relationship. My first sanitizer worked perfectly against a synthetic deck I built to test it, then failed on every single real document, because real documents have links in them. `IsRecoverableOpenFailure` catching `IOException` looks wrong and isn't. When the SDK's `Load()` fails it runs its own cleanup, `Close()` to `DeleteUnusedDataPartOnClose()` to `Package.DeletePart()`, and that last call throws `IOException("Cannot modify a read-only container")` because the package was opened read-only. That second exception REPLACES the `OpenXmlPackageException` that caused it. So if you only catch the one you'd expect, your recovery never runs and you're left staring at a read-only error that explains nothing. Same cleanup also runs on successful disposal, which is what `DisposeQuietly` is for: without it a document that opened and extracted perfectly can still throw on the closing brace of the using block and take your extracted text with it. Word and Excel ride the same defective content-type table, so all three OOXML extractors go through the same reader. The catch-and-retry shape means a clean document costs you nothing extra, the sanitizer only runs after the SDK has already refused the file. ### Not every file that claims to be a zip is one Sitefinity picks the extractor from the document's stored MIME type, and that MIME type comes from the file EXTENSION at upload time. Never from the bytes. So an author renames a legacy `.xls` to `.xlsx` and it lands in the OOXML extractor without being a zip at all. More common than that: a password-protected Office file isn't a zip either, because the encryption wraps the whole OOXML package inside an OLE2 compound file. Neither is readable by the OpenXML SDK, ever, and neither is a defect you can fix in code. Checking the magic number first means those skip quietly instead of generating error reports nobody can action:
FileSignature.csView on GitHub
using System;
using System.IO;

namespace Sitefinity.TextExtractors
{
    /// <summary>
    /// Magic-number sniffing for the document text extractors.
    ///
    /// Sitefinity picks an extractor from the document's stored mime type, which is derived from
    /// the file EXTENSION at upload time and never from the bytes. Two consequences show up in
    /// any real library:
    ///
    ///   - a legacy binary .xls renamed to .xlsx reaches the OOXML extractor and is not a zip
    ///   - far more commonly, a password-protected Office file is not a zip either, because
    ///     encryption wraps the entire OOXML package inside an OLE2 compound file
    ///
    /// The OpenXML SDK can never read either, and its failure (FileFormatException, "File
    /// contains corrupted data") is a property of the upload rather than a defect to fix. So the
    /// extractors check the signature first and skip, instead of reporting an error nobody can
    /// action.
    /// </summary>
    internal static class FileSignature
    {
        /// <summary>
        /// True when the stream starts with the zip magic every OOXML package must begin with.
        /// A stream that cannot be sniffed gets the benefit of the doubt: better to let the SDK
        /// try and fail than to skip a file that was perfectly readable.
        /// </summary>
        internal static bool IsZip(Stream doc)
        {
            var header = ReadHeader(doc);
            if (header == null)
            {
                return true;
            }

            return IsZip(header);
        }

        /// <summary>
        /// Short label for error-report context, so a future failure arrives already diagnosed
        /// instead of as a bare "corrupted data". Comparing this against the document's declared
        /// mime type is usually the whole diagnosis. Never throws.
        /// </summary>
        internal static string Describe(Stream doc)
        {
            try
            {
                var header = ReadHeader(doc);
                if (header == null)
                {
                    return "unreadable";
                }
                if (header.Length == 0)
                {
                    return "empty";
                }
                if (IsZip(header))
                {
                    return "zip";
                }
                if (StartsWith(header, 0xD0, 0xCF, 0x11, 0xE0))
                {
                    return "ole2 (legacy or password-protected office file)";
                }
                if (StartsWith(header, 0x25, 0x50, 0x44, 0x46))
                {
                    return "pdf";
                }

                return BitConverter.ToString(header);
            }
            catch (Exception ex)
            {
                return ex.GetType().Name;
            }
        }

        // "PK" covers the local-header, empty-archive and spanned-archive variants
        private static bool IsZip(byte[] header)
        {
            return StartsWith(header, 0x50, 0x4B);
        }

        private static bool StartsWith(byte[] header, params byte[] magic)
        {
            if (header.Length < magic.Length)
            {
                return false;
            }

            for (var i = 0; i < magic.Length; i++)
            {
                if (header[i] != magic[i])
                {
                    return false;
                }
            }

            return true;
        }

        /// <summary>
        /// Returns the first bytes of the package, or null when the stream cannot be sniffed
        /// without consuming bytes the caller still needs. Always restores the original
        /// position, because the SDK reads the same stream immediately afterwards.
        /// </summary>
        private static byte[] ReadHeader(Stream doc)
        {
            if (doc == null || !doc.CanRead || !doc.CanSeek)
            {
                return null;
            }

            var origin = doc.Position;
            try
            {
                doc.Seek(0, SeekOrigin.Begin);

                // Read can return short of the request even with bytes remaining, so loop
                var buffer = new byte[8];
                var filled = 0;
                while (filled < buffer.Length)
                {
                    var read = doc.Read(buffer, filled, buffer.Length - filled);
                    if (read <= 0)
                    {
                        break;
                    }
                    filled += read;
                }

                var header = new byte[filled];
                Array.Copy(buffer, header, filled);
                return header;
            }
            finally
            {
                doc.Seek(origin, SeekOrigin.Begin);
            }
        }
    }
}
## Indexing Excel spreadsheets Spreadsheets keep their strings in a shared-string table rather than inline, so a naive walk over cells hands you a pile of integer indices. You have to dereference them.
XlsxTextExtractor.csView on GitHub
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Text;
using DocumentFormat.OpenXml.Packaging;
using Telerik.Sitefinity.Services.Documents;

namespace Sitefinity.TextExtractors
{
    /// <summary>
    /// Sitefinity ships no spreadsheet extractor, so xlsx / xlsm / xltx documents index with a
    /// title and no body text.
    ///
    /// Register one entry per mime type in DocumentServiceConfig.config; see registration.config.
    /// </summary>
    public class XlsxTextExtractor : ITextExtractor
    {
        public string MimeType { get; private set; }

        public void Initialize(string mimeType, NameValueCollection config)
        {
            // SpreadsheetDocument opens xlsm and xltx the same way it opens xlsx
            this.MimeType = mimeType;
        }

        public void GetText(Stream doc, Stream text)
        {
            ExtractorGuard.Run(nameof(XlsxTextExtractor), "ExtractText", () => TextExtractorOutput.BuildContext(this.MimeType, doc), () =>
            {
                OpenXmlPackageReader.Read(doc, package => this.ExtractCore(package, text));
            });
        }

        private void ExtractCore(Stream doc, Stream text)
        {
            var builder = new StringBuilder();

            // Not a using block: see DisposeQuietly in OpenXmlPackageReader
            var spreadsheet = SpreadsheetDocument.Open(doc, false);
            try
            {
                var workbookPart = spreadsheet.WorkbookPart;
                if (workbookPart == null)
                {
                    return;
                }

                // THE THING THAT SURPRISES PEOPLE ABOUT XLSX: cell text is not stored in the
                // cell. Strings live once in a shared-string table and each cell holds an
                // integer index into it, so a naive walk over cells hands you a pile of numbers
                // and no words. Load the table first, then dereference.
                var sharedStrings = new List<string>();
                var sharedStringPart = workbookPart.SharedStringTablePart;
                if (sharedStringPart != null)
                {
                    sharedStrings = sharedStringPart.SharedStringTable
                        .Elements<DocumentFormat.OpenXml.Spreadsheet.SharedStringItem>()
                        .Select(item => item.InnerText)
                        .ToList();
                }

                foreach (var sheetPart in workbookPart.WorksheetParts)
                {
                    foreach (var cell in sheetPart.Worksheet.Descendants<DocumentFormat.OpenXml.Spreadsheet.Cell>())
                    {
                        if (cell.CellValue == null)
                        {
                            continue;
                        }

                        var value = cell.CellValue.InnerText;
                        if (cell.DataType != null && cell.DataType.Value == DocumentFormat.OpenXml.Spreadsheet.CellValues.SharedString)
                        {
                            // Bounds-check rather than trust the index: a corrupt or truncated
                            // shared-string table would otherwise throw for the whole document
                            // when the cost of one bad cell should be one bad cell.
                            int index;
                            if (int.TryParse(value, out index) && index >= 0 && index < sharedStrings.Count)
                            {
                                builder.AppendLine(sharedStrings[index]);
                            }
                        }
                        else
                        {
                            // Inline strings, numbers and dates. Numbers are worth keeping:
                            // people search for order numbers and student ids.
                            builder.AppendLine(value);
                        }
                    }
                }
            }
            finally
            {
                OpenXmlPackageReader.DisposeQuietly(spreadsheet);
            }

            TextExtractorOutput.WriteUtf8(builder, text);
        }
    }
}
That bounds check on the index isn't ceremony. A corrupt or hand-generated workbook can carry a shared-string index past the end of the table, and an unguarded lookup throws in the middle of indexing over a value nobody would have searched for anyway. Skip the cell, keep the document. ## Indexing macro-enabled Word files (.docm) Smallest of the four and the most purely bureaucratic. A `.docm` file is DOCX with macros, the content is identical. It only misses out because of the MIME type mismatch from earlier.
WordTextExtractor.csView on GitHub
using System.Collections.Specialized;
using System.IO;
using System.Text;
using DocumentFormat.OpenXml.Packaging;
using Telerik.Sitefinity.Services.Documents;

namespace Sitefinity.TextExtractors
{
    /// <summary>
    /// Covers macro-enabled Word (.docm).
    ///
    /// Sitefinity's built-in extractor is registered against the docx mime type only, and docm
    /// has a different one, so macro-enabled documents fall through to no extractor at all and
    /// index title-only. The file format is otherwise identical, which is why this is short.
    ///
    /// Plain .docx should stay with the built-in DefaultTextExtractor: do not register this
    /// against that mime type, there is nothing to gain.
    /// </summary>
    public class WordTextExtractor : ITextExtractor
    {
        public string MimeType { get; private set; }

        public void Initialize(string mimeType, NameValueCollection config)
        {
            this.MimeType = mimeType;
        }

        public void GetText(Stream doc, Stream text)
        {
            ExtractorGuard.Run(nameof(WordTextExtractor), "ExtractText", () => TextExtractorOutput.BuildContext(this.MimeType, doc), () =>
            {
                OpenXmlPackageReader.Read(doc, package => this.ExtractCore(package, text));
            });
        }

        private void ExtractCore(Stream doc, Stream text)
        {
            var builder = new StringBuilder();

            // Not a using block: see DisposeQuietly in OpenXmlPackageReader
            var word = WordprocessingDocument.Open(doc, false);
            try
            {
                var body = word.MainDocumentPart?.Document?.Body;
                if (body == null)
                {
                    return;
                }

                // Paragraph.InnerText concatenates every run inside the paragraph, which is what
                // you want: Word splits a single sentence across runs whenever formatting or
                // spell-check state changes mid-line, so reading runs individually would shred
                // words into fragments that match nothing.
                foreach (var paragraph in body.Descendants<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
                {
                    builder.AppendLine(paragraph.InnerText);
                }

                // Note: this reads the document body only. Headers, footers and footnotes live
                // in separate parts (word.MainDocumentPart.HeaderParts and friends) and are
                // usually boilerplate, so they are skipped on purpose.
            }
            finally
            {
                OpenXmlPackageReader.DisposeQuietly(word);
            }

            TextExtractorOutput.WriteUtf8(builder, text);
        }
    }
}
Leave plain `.docx` with the built-in extractor. No reason to take ownership of a format Sitefinity already handles. ## Failure behavior matters more than the parsing Sitefinity runs inbound publishing pipes through a helper that amounts to `try { action(item); } catch { Log.Error(...); }` with no rethrow. Which keeps one bad file from killing an entire reindex, fair enough. The cost is that an exception in the indexing path makes the document silently disappear from the index. No error page, no failed job, just a document that is no longer findable and a line in a log file nobody reads. So the rule for extractor code is that a failure should cost you one document's body text. Never the document itself, and never the run. Don't let an exception escape `GetText` if you can avoid it. Catch it, report it somewhere you actually look, and return with an empty output stream so the document still gets indexed by title and metadata. Report to your error tracker rather than only the log file. I route extractor failures to Sentry with the MIME type and stream length attached. One caveat worth designing around: `GetText` only receives a stream, so the document ID lives in the caller and isn't available to you. If you need to identify the specific file, match on timestamp against the log. And cap the reporting. A systemic problem during a full reindex fails once per document, and twenty thousand documents means twenty thousand identical events, which is exactly how a useful signal ends up muted as noise. I cap at ten per extractor per app domain and carry a counter in the payload so the real scale stays visible. All three of those live in one place, so the extractors just wrap their work in it:
ExtractorGuard.csView on GitHub
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;

namespace Sitefinity.TextExtractors
{
    /// <summary>
    /// Containment boundary for search-index extraction.
    ///
    /// WHY THIS EXISTS, because it looks like ceremony around a try/catch:
    /// Telerik runs every inbound pipe through PublishingHelper.ForEachSafe, which swallows
    /// exceptions and silently DROPS the item from the Lucene index. An unguarded
    /// NullReferenceException in a document pipe will quietly unindex documents for as long as
    /// it takes somebody to notice they cannot find a file. Wrapping each step caps the blast
    /// radius of a failure at one document's body text instead of the whole item, and reports it
    /// somewhere a human will actually see.
    ///
    /// THE CAP IS NOT OPTIONAL: a systemic data problem during a full reindex fails once per
    /// item, so 20,000 documents means 20,000 identical error reports, which is the same as
    /// having none. The first few carry the signal and the counter preserves the true scale.
    /// </summary>
    internal static class ExtractorGuard
    {
        /// <summary>
        /// Wire this up once at startup to Sentry, Raygun, log4net, or whatever you use:
        /// (exception, contextData, message). Left null, failures are contained silently.
        /// </summary>
        internal static Action<Exception, Dictionary<string, string>, string> Report;

        private const int MaxReportsPerStep = 10;
        private static readonly ConcurrentDictionary<string, int> ReportCounts = new ConcurrentDictionary<string, int>();

        internal static void Run(string pipeName, string step, Func<Dictionary<string, string>> contextBuilder, Action action)
        {
            try
            {
                action();
            }
            catch (Exception ex)
            {
                var failureCount = ReportCounts.AddOrUpdate($"{pipeName}.{step}", 1, (key, count) => count + 1);
                if (failureCount > MaxReportsPerStep)
                {
                    return;
                }

                var customData = new Dictionary<string, string>
                {
                    { "pipe", pipeName },
                    { "step", step },
                    { "failureCountThisAppDomain", failureCount.ToString() },
                    { "reportingCapped", (failureCount == MaxReportsPerStep).ToString() }
                };

                // Context readers touch lazy-loaded Sitefinity properties and may throw on a
                // background thread. A catch block that throws is worse than the original bug,
                // so a failure to build context must never mask the exception being reported.
                try
                {
                    if (contextBuilder != null)
                    {
                        foreach (var pair in contextBuilder())
                        {
                            customData[pair.Key] = pair.Value ?? "null";
                        }
                    }
                }
                catch (Exception contextEx)
                {
                    customData["contextError"] = contextEx.Message;
                }

                var report = Report;
                if (report != null)
                {
                    report(ex, customData, $"{pipeName}.{step} failed; the item was indexed without this data");
                }
            }
        }
    }
}
That last one turned out to be the biggest quality-of-life win of the whole exercise. Before, a reindex dumped a sixty-plus-line Telerik stack trace into the error log for every failing PDF, hundreds of times over, burying anything else happening on the site. Same situation now produces ten events with a count attached, and a log you can still read. ## After deploying it Formats that had no extractor at all now index their contents: slides plus speaker notes, spreadsheet cells, macro-enabled documents. The "MIME type is not supported" warnings stopped, because those MIME types are now supported. On the PDF side, the structurally broken legacy files that the stock extractor gave up on during import come back with text. Not all of them, image-only scans still need OCR, but the ones whose only sin was a malformed structure tree or a `RichMedia` annotation are fine now. It's maybe 300 lines total and most of that is boilerplate around four small parsers. Almost none of the time went into the parsing. # Vue 3 + Vite 8 + Tailwind CSS v4 on Sitefinity CMS: Complete Setup Guide with Code Splitting > **Stack:** Sitefinity 15+ (ASP.NET MVC) | Vue 3.5 | Vite 8 (Rolldown) | Tailwind CSS v4 | shadcn-vue | TypeScript > > This guide walks you through adding a modern Vue 3 frontend to a Sitefinity CMS project with production code splitting, design-mode support, search indexing, and a dev impersonation controller. Every file is included, you can hand this to an LLM or a developer and get a working setup. > > **Updated (June 2026)** after months of running this stack in production: added the Sentry error bridge (Vue 3 silently swallows widget errors -- you want this), corrected the `[ColorPalette]` prerequisite (it does not exist in MVC, verified by reflection), bumped dependency pins to what actually ships today, documented the ` } else { var jsPath = "/ResourcePackages/MyTheme/assets/dist/vue3/vue3-runtime.js"; var cssPath = "/ResourcePackages/MyTheme/assets/dist/vue3/vue3-runtime.css"; @* ES module, enables dynamic import() for chunk loading. Module scripts are deferred by default, so execution order is the same as placing a classic @Html.StyleSheet(cssPath + "?v=" + Util.FileHash(cssPath), "head") } ``` ### Include in Your Layout In your Sitefinity layout `.cshtml` (e.g., `Mvc/Views/Layouts/default.cshtml`), add the partial at the end of the ``: ```razor @* Sitefinity head sections *@ @Html.Section("head") @* Page content *@ @Html.SfPlaceHolder("Body") @* Sitefinity script sections *@ @Html.Section("scripts") @* Vue 3 runtime, must be AFTER all Sitefinity sections *@ @Html.Partial("Vue3") ``` The `` works too -- a JSON script block is inert and survives HTML processing that can occasionally interfere with `